stylelint-plugin-rhythmguard 3.10.2 → 3.12.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 +77 -0
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/audit/args.js +22 -2
- package/src/audit/baseline.js +184 -27
- package/src/audit/codemod.js +39 -14
- package/src/audit/config.js +25 -31
- package/src/audit/contract.js +96 -10
- package/src/audit/render-github.js +4 -0
- package/src/audit/render-html.js +7 -0
- package/src/audit/render-markdown.js +27 -0
- package/src/audit/render-text.js +23 -1
- package/src/audit/report.js +60 -17
- package/src/audit/scan/files.js +154 -27
- package/src/audit/scan/stylesheets.js +87 -34
- package/src/audit/scan/templates.js +22 -7
- package/src/audit/token-chains.js +18 -7
- package/src/cli/audit.js +7 -3
- package/src/cli/doctor.js +48 -7
- package/src/cli/fix.js +56 -15
- package/src/cli/init.js +136 -23
- package/src/cli/quickstart.js +34 -8
- package/src/core/fs-cache.js +6 -0
- package/src/core/length.js +15 -0
- package/src/core/options.js +8 -1
- package/src/core/scale-inference.js +99 -68
- package/src/core/tailwind-class-analysis.js +26 -16
- package/src/core/tailwind-motion-analysis.js +12 -3
- package/src/core/token-index.js +76 -24
- package/src/core/token-map.js +23 -10
- package/src/core/token-source-entries.js +64 -0
- package/src/core/token-sources.js +361 -69
- package/src/core/value-nodes.js +280 -54
- package/src/eslint/literal-spans.js +319 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +9 -32
- package/src/eslint/rules/tailwind-class-use-scale.js +45 -40
- package/src/rules/no-offscale-transform/index.js +35 -14
- package/src/rules/prefer-token/index.js +72 -48
- package/src/rules/report.js +3 -1
- package/src/rules/use-motion-scale/index.js +68 -5
- package/src/rules/use-scale/index.js +54 -39
- package/types/__checks__/eslint-config.ts +37 -0
- package/types/audit.d.ts +10 -2
- package/types/shared.d.ts +21 -3
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/length');
|
|
5
|
-
const { customPropertyDeclarations, parseTokenValueLength } = require('../core/token-sources');
|
|
5
|
+
const { customPropertyDeclarations, hasLineComments, parseTokenValueLength } = require('../core/token-sources');
|
|
6
6
|
const { formatPath } = require('./shared');
|
|
7
7
|
|
|
8
8
|
/**
|
|
@@ -33,7 +33,7 @@ function isSpacingTokenName(token) {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** Every custom property declaration the audit can see: scanned stylesheets plus external token definitions. */
|
|
36
|
-
function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => false }) {
|
|
36
|
+
function collectDeclarations({ cssFiles, externalDefinitions, readSource = (file) => fs.readFileSync(file, 'utf8'), skipFile = () => false }) {
|
|
37
37
|
const declarations = new Map();
|
|
38
38
|
const add = (token, value) => {
|
|
39
39
|
if (!declarations.has(token)) declarations.set(token, new Set());
|
|
@@ -43,11 +43,12 @@ function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => f
|
|
|
43
43
|
if (skipFile(formatPath(filePath))) continue;
|
|
44
44
|
let source;
|
|
45
45
|
try {
|
|
46
|
-
source =
|
|
46
|
+
source = readSource(filePath);
|
|
47
47
|
} catch {
|
|
48
48
|
continue;
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
const lineComments = hasLineComments(filePath);
|
|
51
|
+
for (const declaration of customPropertyDeclarations(source, { lineComments })) add(declaration.token, declaration.value);
|
|
51
52
|
}
|
|
52
53
|
for (const definition of (externalDefinitions || new Map()).values()) {
|
|
53
54
|
if (!definition.token.startsWith('--')) continue;
|
|
@@ -56,21 +57,31 @@ function collectDeclarations({ cssFiles, externalDefinitions, skipFile = () => f
|
|
|
56
57
|
return declarations;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
/**
|
|
61
|
+
* `declarations` are the tokens reported on; `references` are looked up only,
|
|
62
|
+
* so a chain can end in a token package or a token of another kind without
|
|
63
|
+
* that token becoming an entry of its own (issue #259).
|
|
64
|
+
*/
|
|
65
|
+
function resolveTokenChains({ baseFontSize = 16, declarations, references = null, scale }) {
|
|
60
66
|
const scalePx = (scale || []).map(Number).filter(Number.isFinite);
|
|
61
67
|
const memo = new Map();
|
|
68
|
+
const declared = (token) => declarations.get(token) || (references ? references.get(token) : undefined);
|
|
62
69
|
|
|
63
70
|
const classifyValue = (raw, stack, via) => {
|
|
64
71
|
const value = String(raw).trim();
|
|
65
72
|
const length = parseTokenValueLength(value) || parseLengthToken(value);
|
|
66
73
|
if (length && !/^var\(/i.test(value)) {
|
|
74
|
+
// A non-zero number without a unit (`--space-ratio: 1.5`) is a ratio, not 1.5px (issue #268).
|
|
75
|
+
if (length.unit === '' && length.number !== 0) {
|
|
76
|
+
return { outcomes: ['non-length'], terminals: [] };
|
|
77
|
+
}
|
|
67
78
|
const px = toPx(Math.abs(length.number), length.unit || 'px', baseFontSize);
|
|
68
79
|
return px === null ? { outcomes: ['non-length'], terminals: [] } : { outcomes: [], terminals: [formatLength(px, 'px')] };
|
|
69
80
|
}
|
|
70
81
|
const reference = value.match(VAR_REFERENCE);
|
|
71
82
|
if (reference) {
|
|
72
83
|
const [, target, fallback] = reference;
|
|
73
|
-
if (
|
|
84
|
+
if (declared(target)) {
|
|
74
85
|
via.push(target);
|
|
75
86
|
return resolve(target, stack);
|
|
76
87
|
}
|
|
@@ -90,7 +101,7 @@ function resolveTokenChains({ baseFontSize = 16, declarations, scale }) {
|
|
|
90
101
|
if (stack.includes(token)) {
|
|
91
102
|
return { outcomes: ['unresolved'], reason: `cycle through \`${stack[stack.length - 1]}\``, terminals: [], via: [] };
|
|
92
103
|
}
|
|
93
|
-
const values =
|
|
104
|
+
const values = declared(token);
|
|
94
105
|
if (!values) return { outcomes: ['unresolved'], reason: `\`${token}\` is not declared`, terminals: [], via: [] };
|
|
95
106
|
const nextStack = [...stack, token];
|
|
96
107
|
const outcomes = new Set();
|
package/src/cli/audit.js
CHANGED
|
@@ -17,7 +17,7 @@ const { renderBadge } = require('../audit/render-badge');
|
|
|
17
17
|
const { renderHtml } = require('../audit/render-html');
|
|
18
18
|
const { renderMarkdown } = require('../audit/render-markdown');
|
|
19
19
|
const { renderText } = require('../audit/render-text');
|
|
20
|
-
const { createAuditReport } = require('../audit/report');
|
|
20
|
+
const { createAuditReport, normalizeCreateAuditOptions } = require('../audit/report');
|
|
21
21
|
|
|
22
22
|
const args = process.argv.slice(3);
|
|
23
23
|
|
|
@@ -41,8 +41,12 @@ async function run() {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
let report;
|
|
44
|
+
let options;
|
|
44
45
|
try {
|
|
45
|
-
|
|
46
|
+
// Apply .rhythmguardrc.json once, here, so the exit gates (maxFindings,
|
|
47
|
+
// minCleanliness) read the same merged options the report was built from.
|
|
48
|
+
options = { ...normalizeCreateAuditOptions(parsed), configApplied: true };
|
|
49
|
+
report = await createAuditReport(options);
|
|
46
50
|
} catch (err) {
|
|
47
51
|
process.stderr.write(`${err.message}\n`);
|
|
48
52
|
process.exit(1);
|
|
@@ -53,7 +57,7 @@ async function run() {
|
|
|
53
57
|
return;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
const auditFailures = getAuditFailures(report,
|
|
60
|
+
const auditFailures = getAuditFailures(report, options);
|
|
57
61
|
|
|
58
62
|
if (parsed.format === 'json') {
|
|
59
63
|
writeOutput(`${JSON.stringify(toAuditContractReport(report), null, 2)}\n`, parsed.outputPath);
|
package/src/cli/doctor.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
|
+
const { applyAuditConfig, loadAuditConfig, resolveIgnorePatterns } = require('../audit/config');
|
|
6
|
+
const { createDefaultAuditOptions } = require('../audit/shared');
|
|
7
|
+
const { normalizeDecisions } = require('../core/decisions');
|
|
5
8
|
const { normalizeTokenSourceFormat } = require('../core/token-sources');
|
|
6
9
|
|
|
7
10
|
const cwd = process.cwd();
|
|
@@ -221,18 +224,56 @@ function checkRhythmguardConfig() {
|
|
|
221
224
|
return;
|
|
222
225
|
}
|
|
223
226
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
227
|
+
// Validate with the audit's own loader, so doctor passes exactly the files
|
|
228
|
+
// `rhythmguard audit` accepts. The "audit" section is optional there too.
|
|
229
|
+
// Decisions are checked on their own, so an error in one section does not
|
|
230
|
+
// hide an error in the other.
|
|
231
|
+
let configResult = null;
|
|
232
|
+
let options = null;
|
|
233
|
+
try {
|
|
234
|
+
configResult = loadAuditConfig({});
|
|
235
|
+
const applied = applyAuditConfig(
|
|
236
|
+
{ ...createDefaultAuditOptions(), cliOptions: new Set() },
|
|
237
|
+
{ ...configResult, decisions: undefined },
|
|
238
|
+
);
|
|
239
|
+
resolveIgnorePatterns(applied);
|
|
240
|
+
options = applied;
|
|
241
|
+
pass('.rhythmguardrc.json audit config valid');
|
|
242
|
+
} catch (err) {
|
|
243
|
+
fail(
|
|
244
|
+
`.rhythmguardrc.json audit config invalid: ${err.message}`,
|
|
245
|
+
'Fix the "audit" section; rhythmguard audit stops on the same error',
|
|
246
|
+
);
|
|
228
247
|
}
|
|
229
248
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
249
|
+
checkRhythmguardDecisions(parsed, options ? options.baseFontSize : undefined);
|
|
250
|
+
|
|
251
|
+
const audit = configResult ? configResult.audit : {};
|
|
252
|
+
if (options) {
|
|
253
|
+
checkRhythmguardTokenSources(audit, path.dirname(configPath));
|
|
254
|
+
checkRhythmguardMotionConfig(audit);
|
|
255
|
+
}
|
|
233
256
|
checkRhythmguardBaseline(audit);
|
|
234
257
|
}
|
|
235
258
|
|
|
259
|
+
function checkRhythmguardDecisions(config, baseFontSize) {
|
|
260
|
+
const raw = config && typeof config === 'object' && !Array.isArray(config) ? config.decisions : undefined;
|
|
261
|
+
if (raw === undefined || raw === null) {
|
|
262
|
+
skip('decisions check skipped (not configured)');
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const decisions = normalizeDecisions(raw, baseFontSize === undefined ? {} : { baseFontSize });
|
|
268
|
+
pass(`.rhythmguardrc.json decisions valid (${decisions.length} decision${decisions.length === 1 ? '' : 's'})`);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
fail(
|
|
271
|
+
`.rhythmguardrc.json decisions invalid: ${err.message}`,
|
|
272
|
+
'Fix the "decisions" section; rhythmguard audit and the Stylelint rules read the same entries',
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
236
277
|
function checkRhythmguardTokenSources(audit, baseDir) {
|
|
237
278
|
if (audit.tokenSources === undefined) {
|
|
238
279
|
skip('token source config check skipped (not configured)');
|
package/src/cli/fix.js
CHANGED
|
@@ -2,7 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
const { loadRcDecisions } = require('../core/decisions');
|
|
4
4
|
const { parseLengthToken, toPx } = require('../core/length');
|
|
5
|
+
const { OPTIONS: AUDIT_OPTIONS } = require('../audit/args');
|
|
5
6
|
const { runCodemod } = require('../audit/codemod');
|
|
7
|
+
const { resolveIgnorePatterns } = require('../audit/config');
|
|
8
|
+
const { normalizeCreateAuditOptions } = require('../audit/report');
|
|
9
|
+
const { DEFAULT_IGNORE_PATH } = require('../audit/shared');
|
|
10
|
+
|
|
11
|
+
// --ignore and --ignore-path are the audit's own flags, parsed by the audit's table.
|
|
12
|
+
const IGNORE_OPTIONS = AUDIT_OPTIONS.filter((option) => option.flag === '--ignore' || option.flag === '--ignore-path');
|
|
6
13
|
|
|
7
14
|
const HELP = `Usage: rhythmguard fix <dir> --value <length> --to <replacement> [options]
|
|
8
15
|
rhythmguard fix <dir> --decided [options]
|
|
@@ -11,7 +18,9 @@ Replace one spacing literal everywhere it appears on a spacing property, or
|
|
|
11
18
|
execute the snap decisions in .rhythmguardrc.json that name a "to". Matching is
|
|
12
19
|
by px, so --value 10px also rewrites 0.625rem. Token definitions, values inside
|
|
13
20
|
var()/theme()/token(), and non-spacing properties are never touched. Dry run by
|
|
14
|
-
default.
|
|
21
|
+
default. Files the audit skips are skipped too: .rhythmguardignore, audit.ignore
|
|
22
|
+
in .rhythmguardrc.json, --ignore, and generated or vendored directories such as
|
|
23
|
+
node_modules and dist.
|
|
15
24
|
|
|
16
25
|
Options:
|
|
17
26
|
--value <length> The literal to replace, for example 10px or 0.625rem
|
|
@@ -19,16 +28,32 @@ Options:
|
|
|
19
28
|
--decided Apply every snap decision that has a "to"
|
|
20
29
|
--properties <list> Comma-separated property names or prefix-* patterns (default: spacing properties)
|
|
21
30
|
--base-font-size <px> Base for rem/em conversion (default: 16)
|
|
31
|
+
--ignore <pattern> Exclude a root-relative path or glob (repeatable, comma-separated)
|
|
32
|
+
--ignore-path <file> Load ignore patterns from file (default: .rhythmguardignore when present)
|
|
22
33
|
--write Apply the changes; without it, only list them
|
|
23
34
|
--help Show this help message
|
|
24
35
|
`;
|
|
25
36
|
|
|
26
37
|
function parseArgs(argv) {
|
|
27
|
-
const parsed = {
|
|
38
|
+
const parsed = {
|
|
39
|
+
baseFontSize: 16,
|
|
40
|
+
cliOptions: new Set(),
|
|
41
|
+
decided: false,
|
|
42
|
+
dir: null,
|
|
43
|
+
help: false,
|
|
44
|
+
ignorePath: DEFAULT_IGNORE_PATH,
|
|
45
|
+
ignorePatterns: [],
|
|
46
|
+
properties: null,
|
|
47
|
+
replacement: null,
|
|
48
|
+
value: null,
|
|
49
|
+
write: false,
|
|
50
|
+
};
|
|
28
51
|
for (let index = 0; index < argv.length; index += 1) {
|
|
29
52
|
const arg = argv[index];
|
|
30
53
|
const next = () => argv[++index];
|
|
31
|
-
|
|
54
|
+
const ignoreOption = IGNORE_OPTIONS.find((option) => arg === option.flag || arg.startsWith(`${option.flag}=`));
|
|
55
|
+
if (ignoreOption) ignoreOption.apply(parsed, arg === ignoreOption.flag ? next() : arg.slice(ignoreOption.flag.length + 1));
|
|
56
|
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
|
32
57
|
else if (arg === '--write') parsed.write = true;
|
|
33
58
|
else if (arg === '--decided') parsed.decided = true;
|
|
34
59
|
else if (arg === '--value') parsed.value = next();
|
|
@@ -61,6 +86,19 @@ function lengthToPx(value, baseFontSize) {
|
|
|
61
86
|
return px;
|
|
62
87
|
}
|
|
63
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The ignore list, loaded exactly as the audit loads it: .rhythmguardrc.json's
|
|
91
|
+
* audit.ignore and audit.ignorePath, the ignore file, then --ignore.
|
|
92
|
+
*/
|
|
93
|
+
function auditIgnorePatterns(parsed) {
|
|
94
|
+
return resolveIgnorePatterns(normalizeCreateAuditOptions({
|
|
95
|
+
cliOptions: parsed.cliOptions,
|
|
96
|
+
dir: parsed.dir,
|
|
97
|
+
ignorePath: parsed.ignorePath,
|
|
98
|
+
ignorePatterns: parsed.ignorePatterns,
|
|
99
|
+
}));
|
|
100
|
+
}
|
|
101
|
+
|
|
64
102
|
/** The replacements to run: one from --value/--to, or every snap decision with a "to". */
|
|
65
103
|
function plannedReplacements(parsed) {
|
|
66
104
|
if (parsed.decided) {
|
|
@@ -98,8 +136,10 @@ function run() {
|
|
|
98
136
|
}
|
|
99
137
|
|
|
100
138
|
let plan;
|
|
139
|
+
let ignorePatterns;
|
|
101
140
|
try {
|
|
102
141
|
plan = plannedReplacements(parsed);
|
|
142
|
+
ignorePatterns = auditIgnorePatterns(parsed);
|
|
103
143
|
} catch (error) {
|
|
104
144
|
process.stderr.write(`${error.message}\n`);
|
|
105
145
|
process.exit(1);
|
|
@@ -108,25 +148,26 @@ function run() {
|
|
|
108
148
|
const lines = [];
|
|
109
149
|
let total = 0;
|
|
110
150
|
const touched = new Set();
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
151
|
+
// Every replacement in one pass over the original values: a value one snap
|
|
152
|
+
// writes is never snapped again, and the dry run lists exactly what --write writes.
|
|
153
|
+
const outcome = plan.replacements.length === 0
|
|
154
|
+
? { results: [], skipped: [] }
|
|
155
|
+
: runCodemod({
|
|
114
156
|
baseFontSize: parsed.baseFontSize,
|
|
115
157
|
dir: parsed.dir,
|
|
158
|
+
ignorePatterns,
|
|
116
159
|
properties: parsed.properties,
|
|
117
|
-
|
|
118
|
-
replacement: replacement.replacement,
|
|
160
|
+
replacements: plan.replacements,
|
|
119
161
|
write: parsed.write,
|
|
120
162
|
});
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
163
|
+
for (const result of outcome.results) {
|
|
164
|
+
touched.add(result.file);
|
|
165
|
+
for (const change of result.changes) {
|
|
166
|
+
total += 1;
|
|
167
|
+
lines.push(` ${result.file}:${change.line}:${change.column} ${change.property}: ${change.from} -> ${change.to}`);
|
|
127
168
|
}
|
|
128
|
-
skipped.push(...outcome.skipped);
|
|
129
169
|
}
|
|
170
|
+
const skipped = outcome.skipped;
|
|
130
171
|
|
|
131
172
|
if (lines.length > 0) {
|
|
132
173
|
lines.push('');
|
package/src/cli/init.js
CHANGED
|
@@ -72,23 +72,117 @@ function detect() {
|
|
|
72
72
|
fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
|
|
73
73
|
fs.existsSync(path.join(cwd, 'next.config.ts'));
|
|
74
74
|
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.json')) ||
|
|
78
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.js')) ||
|
|
79
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.cjs')) ||
|
|
80
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.mjs')) ||
|
|
81
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.yml')) ||
|
|
82
|
-
fs.existsSync(path.join(cwd, '.stylelintrc.yaml')) ||
|
|
83
|
-
fs.existsSync(path.join(cwd, 'stylelint.config.js')) ||
|
|
84
|
-
fs.existsSync(path.join(cwd, 'stylelint.config.cjs')) ||
|
|
85
|
-
fs.existsSync(path.join(cwd, 'stylelint.config.mjs')) ||
|
|
86
|
-
Boolean(pkg.stylelint);
|
|
75
|
+
const activeConfig = findActiveStylelintConfig(cwd);
|
|
76
|
+
const hasExistingConfig = activeConfig !== null;
|
|
87
77
|
|
|
88
78
|
const tailwind = hasTailwindConfig || hasTailwindDep;
|
|
89
79
|
const nextjs = hasNextConfig;
|
|
90
80
|
|
|
91
|
-
return { tailwind, nextjs, hasExistingConfig };
|
|
81
|
+
return { activeConfig, tailwind, nextjs, hasExistingConfig };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Where Stylelint looks for a config in one directory, in its order. Stylelint
|
|
86
|
+
* searches with cosmiconfig and does not expose the file it picks, so this is
|
|
87
|
+
* cosmiconfig 9's default list for the module name "stylelint". The first hit
|
|
88
|
+
* wins: package.json counts only with a "stylelint" key, and an empty file is
|
|
89
|
+
* skipped, as cosmiconfig skips it.
|
|
90
|
+
*/
|
|
91
|
+
const STYLELINT_SEARCH_PLACES = [
|
|
92
|
+
'package.json',
|
|
93
|
+
'.stylelintrc',
|
|
94
|
+
'.stylelintrc.json',
|
|
95
|
+
'.stylelintrc.yaml',
|
|
96
|
+
'.stylelintrc.yml',
|
|
97
|
+
'.stylelintrc.js',
|
|
98
|
+
'.stylelintrc.ts',
|
|
99
|
+
'.stylelintrc.cjs',
|
|
100
|
+
'.stylelintrc.mjs',
|
|
101
|
+
'.config/stylelintrc',
|
|
102
|
+
'.config/stylelintrc.json',
|
|
103
|
+
'.config/stylelintrc.yaml',
|
|
104
|
+
'.config/stylelintrc.yml',
|
|
105
|
+
'.config/stylelintrc.js',
|
|
106
|
+
'.config/stylelintrc.ts',
|
|
107
|
+
'.config/stylelintrc.cjs',
|
|
108
|
+
'.config/stylelintrc.mjs',
|
|
109
|
+
'stylelint.config.js',
|
|
110
|
+
'stylelint.config.ts',
|
|
111
|
+
'stylelint.config.cjs',
|
|
112
|
+
'stylelint.config.mjs',
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The config file Stylelint loads in `cwd`, or null. `kind` says what init can
|
|
117
|
+
* do with it: 'json' is rewritten in place; 'package' (the package.json key),
|
|
118
|
+
* 'yaml' and 'code' (JS or TS) get a snippet to add by hand, because writing a
|
|
119
|
+
* .stylelintrc.json next to them changes nothing: Stylelint never reads it.
|
|
120
|
+
*/
|
|
121
|
+
function findActiveStylelintConfig(cwd = process.cwd()) {
|
|
122
|
+
for (const file of STYLELINT_SEARCH_PLACES) {
|
|
123
|
+
let content;
|
|
124
|
+
try {
|
|
125
|
+
content = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (file === 'package.json') {
|
|
131
|
+
let pkg = null;
|
|
132
|
+
try {
|
|
133
|
+
pkg = JSON.parse(content);
|
|
134
|
+
} catch {
|
|
135
|
+
// cosmiconfig would throw here; a broken package.json is no Stylelint config
|
|
136
|
+
}
|
|
137
|
+
if (pkg && typeof pkg === 'object' && pkg.stylelint !== undefined && pkg.stylelint !== null) {
|
|
138
|
+
return { file, kind: 'package' };
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (content.trim() === '') {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return { file, kind: configKind(file, content) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function configKind(file, content) {
|
|
154
|
+
const extension = path.extname(file);
|
|
155
|
+
if (extension === '.json') {
|
|
156
|
+
return 'json';
|
|
157
|
+
}
|
|
158
|
+
if (extension === '.yaml' || extension === '.yml') {
|
|
159
|
+
return 'yaml';
|
|
160
|
+
}
|
|
161
|
+
if (extension === '') {
|
|
162
|
+
// .stylelintrc holds JSON or YAML; only JSON is rewritten.
|
|
163
|
+
try {
|
|
164
|
+
JSON.parse(content);
|
|
165
|
+
return 'json';
|
|
166
|
+
} catch {
|
|
167
|
+
return 'yaml';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return 'code';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** What to add to a config init cannot rewrite, in that file's own syntax. */
|
|
174
|
+
function configSnippet(kind, config) {
|
|
175
|
+
if (kind === 'package') {
|
|
176
|
+
return JSON.stringify({ stylelint: config }, null, 2).split('\n').slice(1, -1).map((line) => line.slice(2)).join('\n');
|
|
177
|
+
}
|
|
178
|
+
if (kind === 'yaml') {
|
|
179
|
+
return Object.entries(config)
|
|
180
|
+
.map(([key, values]) => `${key}:\n${values.map((value) => ` - ${JSON.stringify(value)}`).join('\n')}`)
|
|
181
|
+
.join('\n');
|
|
182
|
+
}
|
|
183
|
+
return Object.entries(config)
|
|
184
|
+
.map(([key, values]) => `${key}: ${JSON.stringify(values)},`)
|
|
185
|
+
.join('\n');
|
|
92
186
|
}
|
|
93
187
|
|
|
94
188
|
function selectProfile(stack) {
|
|
@@ -193,6 +287,31 @@ async function run() {
|
|
|
193
287
|
process.stdout.write('Detected: plain CSS project\n');
|
|
194
288
|
}
|
|
195
289
|
|
|
290
|
+
const profile = selectProfile(stack);
|
|
291
|
+
const config = {
|
|
292
|
+
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
293
|
+
// Next.js build output is generated CSS; never lint it.
|
|
294
|
+
...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
// A .stylelintrc.json next to the config Stylelint actually loads is either
|
|
298
|
+
// never read (package.json, a YAML .stylelintrc) or silently takes over
|
|
299
|
+
// from it (YAML, JS and TS files later in the search). When that config is
|
|
300
|
+
// not JSON, say what to add to it and write nothing.
|
|
301
|
+
const active = stack.activeConfig;
|
|
302
|
+
if (active && active.kind !== 'json') {
|
|
303
|
+
const where = active.kind === 'package' ? 'the "stylelint" key in package.json' : active.file;
|
|
304
|
+
const shadowsJson = STYLELINT_SEARCH_PLACES.indexOf(active.file) < STYLELINT_SEARCH_PLACES.indexOf('.stylelintrc.json');
|
|
305
|
+
process.stdout.write(`\nProfile: ${profile}\n`);
|
|
306
|
+
process.stdout.write(shadowsJson
|
|
307
|
+
? `\n⚠ Stylelint loads ${where} before .stylelintrc.json, so it would never read a .stylelintrc.json.\n`
|
|
308
|
+
: `\n⚠ Stylelint loads ${where}; a new .stylelintrc.json would replace it and leave it unused.\n`);
|
|
309
|
+
process.stdout.write(`Add this to ${active.kind === 'package' ? 'package.json' : active.file}, merging with what is there:\n\n`);
|
|
310
|
+
process.stdout.write(`${configSnippet(active.kind, config)}\n\n`);
|
|
311
|
+
process.stdout.write('Nothing was written.\n\n');
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
196
315
|
// Warn about existing config
|
|
197
316
|
if (stack.hasExistingConfig) {
|
|
198
317
|
process.stdout.write('\n⚠ Existing Stylelint config found.\n');
|
|
@@ -203,22 +322,16 @@ async function run() {
|
|
|
203
322
|
}
|
|
204
323
|
}
|
|
205
324
|
|
|
206
|
-
const profile = selectProfile(stack);
|
|
207
325
|
process.stdout.write(`\nProfile: ${profile}\n`);
|
|
208
326
|
|
|
209
|
-
const
|
|
327
|
+
const targetFile = active ? active.file : '.stylelintrc.json';
|
|
328
|
+
const answer = await prompter.ask(`Write ${targetFile}? (y/n) `);
|
|
210
329
|
if (answer !== 'y' && answer !== 'yes') {
|
|
211
330
|
process.stdout.write('Aborted.\n');
|
|
212
331
|
process.exit(0);
|
|
213
332
|
}
|
|
214
333
|
|
|
215
|
-
const
|
|
216
|
-
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
217
|
-
// Next.js build output is generated CSS; never lint it.
|
|
218
|
-
...(stack.nextjs ? { ignoreFiles: ['.next/**', 'out/**', 'node_modules/**'] } : {}),
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
const configPath = path.join(process.cwd(), '.stylelintrc.json');
|
|
334
|
+
const configPath = path.join(process.cwd(), targetFile);
|
|
222
335
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
223
336
|
|
|
224
337
|
process.stdout.write(`\n✓ Wrote ${configPath}\n`);
|
|
@@ -229,7 +342,7 @@ async function run() {
|
|
|
229
342
|
}
|
|
230
343
|
}
|
|
231
344
|
|
|
232
|
-
module.exports = { AGENT_TARGETS, detect, installAgents, run, selectProfile };
|
|
345
|
+
module.exports = { AGENT_TARGETS, detect, findActiveStylelintConfig, installAgents, run, selectProfile };
|
|
233
346
|
|
|
234
347
|
if (require.main === module) {
|
|
235
348
|
run();
|
package/src/cli/quickstart.js
CHANGED
|
@@ -77,9 +77,16 @@ function topEntries(counts, limit = MAX_LISTED) {
|
|
|
77
77
|
.slice(0, limit);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
/**
|
|
81
|
+
* The config to paste. With a scale inferred from tokens it is `scale: "auto"`
|
|
82
|
+
* pointed at the same token files, so Stylelint infers what the audit did.
|
|
83
|
+
* When the audit fell back to its default scale, that scale is written out:
|
|
84
|
+
* `scale: "auto"` would fall back to the `rhythmic-4` preset instead, which is
|
|
85
|
+
* a longer scale, and the pasted config would not reproduce this report (#246).
|
|
86
|
+
*/
|
|
87
|
+
function suggestedStylelintConfig({ fallbackScale = null, nextjs = false, profile, tokenFiles }) {
|
|
88
|
+
const ruleOptions = Array.isArray(fallbackScale) ? { scale: [...fallbackScale] } : { scale: 'auto' };
|
|
89
|
+
if (!fallbackScale && tokenFiles.length > 0) {
|
|
83
90
|
ruleOptions.scaleSources = tokenFiles.map(toPosix);
|
|
84
91
|
}
|
|
85
92
|
return {
|
|
@@ -99,6 +106,9 @@ function eslintSnippet(scaleValues) {
|
|
|
99
106
|
'',
|
|
100
107
|
'export default [',
|
|
101
108
|
' {',
|
|
109
|
+
// Without `files` and JSX parsing, a flat config lints no .jsx at all (#243).
|
|
110
|
+
" files: ['**/*.{js,jsx,mjs,cjs}'],",
|
|
111
|
+
' languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } },',
|
|
102
112
|
" plugins: { 'rhythmguard-tailwind': rhythmguard },",
|
|
103
113
|
` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
|
|
104
114
|
' },',
|
|
@@ -149,7 +159,9 @@ async function run() {
|
|
|
149
159
|
return;
|
|
150
160
|
}
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
const scanned = report.scanned || {};
|
|
163
|
+
const skippedStylesheets = (scanned.scssSkipped || 0) + (scanned.stylelintIgnored || 0);
|
|
164
|
+
if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0 && skippedStylesheets === 0) {
|
|
153
165
|
out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
|
|
154
166
|
out.push(' npx rhythmguard audit ./src', '');
|
|
155
167
|
process.stdout.write(`${out.join('\n')}\n`);
|
|
@@ -161,10 +173,10 @@ async function run() {
|
|
|
161
173
|
out.push(` Scale ${scale.values.join(', ')}`);
|
|
162
174
|
out.push(` Source ${describeScale(scale)}`);
|
|
163
175
|
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
|
|
176
|
+
out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the audit's default scale is being used.`);
|
|
165
177
|
out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
|
|
166
178
|
} else if (scale.source === 'fallback') {
|
|
167
|
-
out.push(
|
|
179
|
+
out.push(" Note No spacing tokens found; the audit's default scale is being used.");
|
|
168
180
|
out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
|
|
169
181
|
}
|
|
170
182
|
out.push('');
|
|
@@ -173,6 +185,17 @@ async function run() {
|
|
|
173
185
|
out.push(' Findings');
|
|
174
186
|
out.push(` CSS files ${report.cssFilesScanned}`);
|
|
175
187
|
out.push(` Template files ${report.templateFilesScanned}`);
|
|
188
|
+
if (skippedStylesheets > 0) {
|
|
189
|
+
const reasons = [
|
|
190
|
+
...(scanned.scssSkipped > 0 ? [`${scanned.scssSkipped} SCSS without postcss-scss`] : []),
|
|
191
|
+
...(scanned.stylelintIgnored > 0 ? [`${scanned.stylelintIgnored} in .stylelintignore`] : []),
|
|
192
|
+
];
|
|
193
|
+
out.push(` Skipped ${skippedStylesheets} not audited (${reasons.join(', ')})`);
|
|
194
|
+
}
|
|
195
|
+
const parseErrors = (report.findings.parseErrors || []).length;
|
|
196
|
+
if (parseErrors > 0) {
|
|
197
|
+
out.push(` Parse errors ${parseErrors} not audited (the stylesheet could not be parsed)`);
|
|
198
|
+
}
|
|
176
199
|
out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
|
|
177
200
|
out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
|
|
178
201
|
out.push(` Cleanliness ${report.scaleCleanliness}%`);
|
|
@@ -192,12 +215,15 @@ async function run() {
|
|
|
192
215
|
|
|
193
216
|
const profile = stack.tailwind ? 'tailwind' : 'recommended';
|
|
194
217
|
out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
|
|
195
|
-
|
|
218
|
+
const fallbackScale = scale.source === 'fallback' ? scale.values : null;
|
|
219
|
+
out.push(JSON.stringify(suggestedStylelintConfig({ fallbackScale, nextjs: stack.nextjs, profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
|
|
196
220
|
out.push('');
|
|
197
221
|
|
|
198
222
|
if (stack.tailwind) {
|
|
199
223
|
out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
|
|
200
224
|
out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
|
|
225
|
+
out.push('', ' TypeScript, Vue, Svelte and Astro files need their parser in a block of their own; see the');
|
|
226
|
+
out.push(' eslint-plugin-rhythmguard README, "Other file types".');
|
|
201
227
|
out.push('');
|
|
202
228
|
}
|
|
203
229
|
|
|
@@ -211,4 +237,4 @@ async function run() {
|
|
|
211
237
|
process.stdout.write(`${out.join('\n')}\n`);
|
|
212
238
|
}
|
|
213
239
|
|
|
214
|
-
module.exports = { findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
|
|
240
|
+
module.exports = { eslintSnippet, findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
|
package/src/core/fs-cache.js
CHANGED
|
@@ -31,6 +31,12 @@ function cachedByFiles(cacheKey, compute) {
|
|
|
31
31
|
return value;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Number of cached entries, for tests that check the cache stays bounded. */
|
|
35
|
+
function cacheSize() {
|
|
36
|
+
return cache.size;
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
module.exports = {
|
|
40
|
+
cacheSize,
|
|
35
41
|
cachedByFiles,
|
|
36
42
|
};
|
package/src/core/length.js
CHANGED
|
@@ -93,6 +93,20 @@ function numbersEqual(a, b) {
|
|
|
93
93
|
return Math.abs(a - b) < EPSILON;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Whether a px value is one of the scale steps. A fix writes rem and em with
|
|
98
|
+
* four decimals, so at a base font size other than 16 the written value can
|
|
99
|
+
* sit up to half a unit in the fourth decimal off the step: 0.6667rem at an
|
|
100
|
+
* 18px base is 12.0006px. For those units the comparison allows exactly that
|
|
101
|
+
* much, so a value a fix wrote is on the scale on the next run (issue #224).
|
|
102
|
+
*/
|
|
103
|
+
function pxOnScale(scalePx, px, unit, baseFontSize) {
|
|
104
|
+
const tolerance = unit === 'rem' || unit === 'em'
|
|
105
|
+
? EPSILON + 0.00005 * Math.abs(baseFontSize)
|
|
106
|
+
: EPSILON;
|
|
107
|
+
return scalePx.some((entry) => Math.abs(entry - px) < tolerance);
|
|
108
|
+
}
|
|
109
|
+
|
|
96
110
|
function normalizeScale(scale, baseFontSize) {
|
|
97
111
|
const normalized = [];
|
|
98
112
|
|
|
@@ -222,5 +236,6 @@ module.exports = {
|
|
|
222
236
|
normalizeScaleByUnit,
|
|
223
237
|
numbersEqual,
|
|
224
238
|
parseLengthToken,
|
|
239
|
+
pxOnScale,
|
|
225
240
|
toPx,
|
|
226
241
|
};
|