stylelint-plugin-rhythmguard 2.0.1 → 2.2.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 +42 -0
- package/CONTRIBUTING.md +24 -5
- package/README.md +45 -768
- package/package.json +15 -6
- package/src/audit/args.js +449 -0
- package/src/audit/baseline.js +114 -0
- package/src/audit/config.js +217 -0
- package/src/audit/contract.js +422 -0
- package/src/audit/render-github.js +39 -0
- package/src/audit/render-html.js +78 -0
- package/src/audit/render-markdown.js +227 -0
- package/src/audit/render-text.js +168 -0
- package/src/audit/render-utils.js +41 -0
- package/src/audit/report.js +189 -0
- package/src/audit/scan.js +546 -0
- package/src/audit/shared.js +108 -0
- package/src/cli/audit.js +22 -2213
- package/src/cli/index.js +11 -5
- package/src/cli/init.js +5 -1
- package/src/cli/quickstart.js +203 -0
- package/src/configs/embed.js +24 -0
- package/src/configs/embed.mjs +4 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
- package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
- package/src/index.js +2 -0
- package/src/rules/no-offscale-transform/index.js +23 -1
- package/src/rules/prefer-token/index.js +29 -1
- package/src/rules/use-motion-scale/index.js +1 -1
- package/src/rules/use-scale/index.js +35 -5
- package/src/utils/length.js +16 -0
- package/src/utils/options.js +53 -3
- package/src/utils/scale-inference.js +255 -0
- package/src/utils/token-map.js +4 -2
- package/src/utils/token-sources.js +32 -2
- package/types/__checks__/consumer.ts +80 -0
- package/types/audit.d.ts +25 -4
- package/types/index.d.ts +5 -0
- package/types/shared.d.ts +22 -1
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
|
@@ -0,0 +1,203 @@
|
|
|
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
|
+
|
|
18
|
+
const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
|
|
19
|
+
const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
|
|
20
|
+
const MAX_LISTED = 5;
|
|
21
|
+
|
|
22
|
+
function readPackage(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function tailwindMajor(pkg) {
|
|
31
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
32
|
+
if (deps['@tailwindcss/postcss'] || deps['@tailwindcss/vite']) {
|
|
33
|
+
return 4;
|
|
34
|
+
}
|
|
35
|
+
const spec = deps.tailwindcss;
|
|
36
|
+
if (!spec) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const match = String(spec).match(/(\d+)/);
|
|
40
|
+
return match ? Number(match[1]) : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function findTokenFiles(cwd) {
|
|
44
|
+
const found = new Set();
|
|
45
|
+
const consider = (dir) => {
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (entry.isFile() && TOKEN_FILE_PATTERN.test(entry.name)) {
|
|
54
|
+
found.add(path.relative(cwd, path.join(dir, entry.name)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
consider(cwd);
|
|
59
|
+
for (const sub of TOKEN_DIRS) {
|
|
60
|
+
consider(path.join(cwd, sub));
|
|
61
|
+
}
|
|
62
|
+
return Array.from(found).sort();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasRhythmguardConfig(cwd) {
|
|
66
|
+
return fs.existsSync(path.join(cwd, '.rhythmguardrc.json'));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toPosix(relativePath) {
|
|
70
|
+
return `./${relativePath.split(path.sep).join('/')}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function topEntries(counts, limit = MAX_LISTED) {
|
|
74
|
+
return Object.entries(counts || {})
|
|
75
|
+
.sort((a, b) => b[1] - a[1])
|
|
76
|
+
.slice(0, limit);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function suggestedStylelintConfig({ profile, tokenFiles }) {
|
|
80
|
+
const ruleOptions = { scale: 'auto' };
|
|
81
|
+
if (tokenFiles.length > 0) {
|
|
82
|
+
ruleOptions.scaleSources = tokenFiles.map(toPosix);
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
|
|
86
|
+
rules: {
|
|
87
|
+
'rhythmguard/use-scale': [true, ruleOptions],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function eslintSnippet(scaleValues) {
|
|
93
|
+
const scale = scaleValues.filter((value) => typeof value === 'number').join(', ');
|
|
94
|
+
return [
|
|
95
|
+
"// eslint.config.js",
|
|
96
|
+
"import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';",
|
|
97
|
+
'',
|
|
98
|
+
'export default [',
|
|
99
|
+
' {',
|
|
100
|
+
" plugins: { 'rhythmguard-tailwind': rhythmguard },",
|
|
101
|
+
` rules: { 'rhythmguard-tailwind/tailwind-class-use-scale': ['warn', { scale: [${scale}] }] },`,
|
|
102
|
+
' },',
|
|
103
|
+
'];',
|
|
104
|
+
].join('\n');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeScale(scale) {
|
|
108
|
+
if (!scale) {
|
|
109
|
+
return 'default';
|
|
110
|
+
}
|
|
111
|
+
const files = scale.files && scale.files.length > 0 ? ` (${scale.files.slice(0, 3).join(', ')}${scale.files.length > 3 ? ', …' : ''})` : '';
|
|
112
|
+
return `${scale.source}${files}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function run() {
|
|
116
|
+
const cwd = process.cwd();
|
|
117
|
+
const pkg = readPackage(cwd);
|
|
118
|
+
const stack = detect();
|
|
119
|
+
const twMajor = stack.tailwind ? tailwindMajor(pkg) : null;
|
|
120
|
+
const tokenFiles = findTokenFiles(cwd);
|
|
121
|
+
const rcPresent = hasRhythmguardConfig(cwd);
|
|
122
|
+
const out = [];
|
|
123
|
+
|
|
124
|
+
out.push('', ' Rhythmguard quickstart', ` ${cwd}`, '');
|
|
125
|
+
out.push(' Detected');
|
|
126
|
+
out.push(` Tailwind ${stack.tailwind ? `v${twMajor || '?'}` : 'no'}`);
|
|
127
|
+
out.push(` Next.js ${stack.nextjs ? 'yes' : 'no'}`);
|
|
128
|
+
out.push(` Stylelint config ${stack.hasExistingConfig ? 'present' : 'none'}`);
|
|
129
|
+
out.push(` Token files ${tokenFiles.length > 0 ? tokenFiles.join(', ') : 'none found'}`);
|
|
130
|
+
out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
|
|
131
|
+
out.push('');
|
|
132
|
+
|
|
133
|
+
let report;
|
|
134
|
+
try {
|
|
135
|
+
report = await createAuditReport({
|
|
136
|
+
dir: '.',
|
|
137
|
+
scale: 'auto',
|
|
138
|
+
...(tokenFiles.length > 0 && !rcPresent
|
|
139
|
+
? { tokenSources: tokenFiles }
|
|
140
|
+
: {}),
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (report.cssFilesScanned === 0 && report.templateFilesScanned === 0) {
|
|
149
|
+
out.push(' No CSS files found here. Run from your project root, or audit a directory directly:');
|
|
150
|
+
out.push(' npx rhythmguard audit ./src', '');
|
|
151
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const scale = report.scale;
|
|
156
|
+
out.push(' Scale');
|
|
157
|
+
out.push(` Scale ${scale.values.join(', ')}`);
|
|
158
|
+
out.push(` Source ${describeScale(scale)}`);
|
|
159
|
+
if (scale.source === 'fallback') {
|
|
160
|
+
out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
|
|
161
|
+
out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
|
|
162
|
+
}
|
|
163
|
+
out.push('');
|
|
164
|
+
|
|
165
|
+
const offScale = report.findings.css.filter((finding) => finding.type === 'off-scale');
|
|
166
|
+
out.push(' Findings');
|
|
167
|
+
out.push(` CSS files ${report.cssFilesScanned}`);
|
|
168
|
+
out.push(` Template files ${report.templateFilesScanned}`);
|
|
169
|
+
out.push(` Off-scale ${offScale.length} in CSS, ${report.findings.tailwind.length} in class strings`);
|
|
170
|
+
out.push(` Token opps ${report.findings.css.length - offScale.length} raw values that match or deserve a token`);
|
|
171
|
+
out.push(` Cleanliness ${report.scaleCleanliness}%`);
|
|
172
|
+
const topValues = topEntries(report.offScaleValues);
|
|
173
|
+
if (topValues.length > 0) {
|
|
174
|
+
out.push(` Top values ${topValues.map(([value, count]) => `${value} ×${count}`).join(', ')}`);
|
|
175
|
+
}
|
|
176
|
+
const topFiles = (report.topAffectedFiles || []).slice(0, 3);
|
|
177
|
+
if (topFiles.length > 0) {
|
|
178
|
+
out.push(` Top files ${topFiles.map((entry) => `${entry.file} (${entry.count})`).join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
out.push('');
|
|
181
|
+
|
|
182
|
+
const profile = stack.tailwind ? (stack.nextjs ? 'react-tailwind' : 'tailwind') : 'recommended';
|
|
183
|
+
out.push(` Paste this into .stylelintrc.json${stack.hasExistingConfig ? ' (merge with your existing config)' : ''}:`, '');
|
|
184
|
+
out.push(JSON.stringify(suggestedStylelintConfig({ profile, tokenFiles }), null, 2).replace(/^/gm, ' '));
|
|
185
|
+
out.push('');
|
|
186
|
+
|
|
187
|
+
if (stack.tailwind) {
|
|
188
|
+
out.push(' Tailwind class strings need the ESLint companion. Paste this into eslint.config.js:', '');
|
|
189
|
+
out.push(eslintSnippet(scale.values).replace(/^/gm, ' '));
|
|
190
|
+
out.push('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
out.push(' Next');
|
|
194
|
+
out.push(' npx stylelint "**/*.css" lint with the config above');
|
|
195
|
+
out.push(' npx rhythmguard audit . --format markdown PR-ready report');
|
|
196
|
+
out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
|
|
197
|
+
out.push(' npx rhythmguard doctor check the setup');
|
|
198
|
+
out.push('');
|
|
199
|
+
|
|
200
|
+
process.stdout.write(`${out.join('\n')}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
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
|
+
};
|
|
@@ -76,6 +76,7 @@ function buildMessage(analysis, segment, lower, upper) {
|
|
|
76
76
|
module.exports = {
|
|
77
77
|
meta: {
|
|
78
78
|
docs: {
|
|
79
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-motion-scale.md',
|
|
79
80
|
description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',
|
|
80
81
|
},
|
|
81
82
|
fixable: 'code',
|
|
@@ -67,6 +67,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
67
67
|
module.exports = {
|
|
68
68
|
meta: {
|
|
69
69
|
docs: {
|
|
70
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-scale.md',
|
|
70
71
|
description: 'Enforce spacing scale for Tailwind arbitrary spacing utilities in class strings',
|
|
71
72
|
},
|
|
72
73
|
fixable: 'code',
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ 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'),
|
|
@@ -22,6 +23,7 @@ module.exports.configs = {
|
|
|
22
23
|
logical: require('./configs/logical'),
|
|
23
24
|
migration: require('./configs/migration'),
|
|
24
25
|
motion: require('./configs/motion'),
|
|
26
|
+
'react-tailwind': require('./configs/react-tailwind'),
|
|
25
27
|
};
|
|
26
28
|
module.exports.eslint = require('./eslint');
|
|
27
29
|
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
|
}
|
|
@@ -269,7 +291,7 @@ ruleFunction.ruleName = ruleName;
|
|
|
269
291
|
ruleFunction.messages = messages;
|
|
270
292
|
ruleFunction.meta = {
|
|
271
293
|
fixable: true,
|
|
272
|
-
url: 'https://github.com/
|
|
294
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/no-offscale-transform.md',
|
|
273
295
|
};
|
|
274
296
|
|
|
275
297
|
module.exports = stylelint.createPlugin(ruleName, ruleFunction);
|
|
@@ -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 !== '%' &&
|
|
@@ -288,7 +316,7 @@ ruleFunction.ruleName = ruleName;
|
|
|
288
316
|
ruleFunction.messages = messages;
|
|
289
317
|
ruleFunction.meta = {
|
|
290
318
|
fixable: true,
|
|
291
|
-
url: 'https://github.com/
|
|
319
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/prefer-token.md',
|
|
292
320
|
};
|
|
293
321
|
|
|
294
322
|
module.exports = stylelint.createPlugin(ruleName, ruleFunction);
|
|
@@ -236,7 +236,7 @@ ruleFunction.ruleName = ruleName;
|
|
|
236
236
|
ruleFunction.messages = messages;
|
|
237
237
|
ruleFunction.meta = {
|
|
238
238
|
fixable: true,
|
|
239
|
-
url: 'https://github.com/
|
|
239
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/use-motion-scale.md',
|
|
240
240
|
};
|
|
241
241
|
|
|
242
242
|
module.exports = stylelint.createPlugin(ruleName, ruleFunction);
|
|
@@ -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,
|
|
@@ -29,13 +30,19 @@ const {
|
|
|
29
30
|
walkTransformTranslateNodes,
|
|
30
31
|
} = require('../../utils/value-utils');
|
|
31
32
|
|
|
33
|
+
const {
|
|
34
|
+
DEFAULT_AUTO_TOKEN_PATTERN,
|
|
35
|
+
autoScaleFallbackNote,
|
|
36
|
+
resolveAutoScale,
|
|
37
|
+
} = require('../../utils/scale-inference');
|
|
38
|
+
|
|
32
39
|
const ruleName = 'rhythmguard/use-scale';
|
|
33
40
|
|
|
34
41
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
35
42
|
invalidPreset: (presetName, presetNames) =>
|
|
36
43
|
`Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
|
|
37
|
-
rejected: (value, lower, upper) =>
|
|
38
|
-
`Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper})
|
|
44
|
+
rejected: (value, lower, upper, note = '') =>
|
|
45
|
+
`Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
|
|
39
46
|
});
|
|
40
47
|
|
|
41
48
|
function getFixedNodeValue(parsedLength, nearestPx, options) {
|
|
@@ -82,7 +89,16 @@ function checkLengthValue({
|
|
|
82
89
|
return false;
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
if (parsedLength.unit === '%'
|
|
92
|
+
if (parsedLength.unit === '%') {
|
|
93
|
+
if (options.allowPercentages) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
report(node.value, decl, node, null, null, '%');
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
|
|
86
102
|
return false;
|
|
87
103
|
}
|
|
88
104
|
|
|
@@ -180,8 +196,21 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
180
196
|
});
|
|
181
197
|
}
|
|
182
198
|
|
|
199
|
+
if (options.scaleAuto) {
|
|
200
|
+
const inference = resolveAutoScale({
|
|
201
|
+
baseFontSize: options.baseFontSize,
|
|
202
|
+
root,
|
|
203
|
+
scaleSources: options.scaleSources,
|
|
204
|
+
tailwindConfigPath: options.tailwindConfigPath,
|
|
205
|
+
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
206
|
+
});
|
|
207
|
+
options.scale = inference.scale;
|
|
208
|
+
options.scaleInference = inference;
|
|
209
|
+
}
|
|
210
|
+
|
|
183
211
|
const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
|
|
184
212
|
const scaleCache = new Map();
|
|
213
|
+
let fallbackNote = autoScaleFallbackNote(options.scaleInference);
|
|
185
214
|
|
|
186
215
|
const getScaleStateForProperty = (prop) => {
|
|
187
216
|
const cached = scaleCache.get(prop);
|
|
@@ -208,7 +237,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
208
237
|
const payload = {
|
|
209
238
|
endIndex,
|
|
210
239
|
index,
|
|
211
|
-
message: messages.rejected(value, lower, upper),
|
|
240
|
+
message: messages.rejected(value, lower, upper, fallbackNote),
|
|
212
241
|
node: decl,
|
|
213
242
|
result,
|
|
214
243
|
ruleName,
|
|
@@ -221,6 +250,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
221
250
|
};
|
|
222
251
|
}
|
|
223
252
|
|
|
253
|
+
fallbackNote = '';
|
|
224
254
|
stylelint.utils.report(payload);
|
|
225
255
|
};
|
|
226
256
|
|
|
@@ -332,7 +362,7 @@ ruleFunction.ruleName = ruleName;
|
|
|
332
362
|
ruleFunction.messages = messages;
|
|
333
363
|
ruleFunction.meta = {
|
|
334
364
|
fixable: true,
|
|
335
|
-
url: 'https://github.com/
|
|
365
|
+
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/use-scale.md',
|
|
336
366
|
};
|
|
337
367
|
|
|
338
368
|
module.exports = stylelint.createPlugin(ruleName, ruleFunction);
|
package/src/utils/length.js
CHANGED
|
@@ -25,6 +25,21 @@ function parseLengthToken(rawValue) {
|
|
|
25
25
|
return { number, raw: value, unit };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* A hairline is a non-zero length that resolves to one CSS pixel or less:
|
|
30
|
+
* 1px, -1px, 0.5px, 0.0625rem. Such values compensate for a border width or a
|
|
31
|
+
* rendering quirk; they are not spacing decisions, so the scale rules exempt
|
|
32
|
+
* them by default (`allowHairlines`).
|
|
33
|
+
*/
|
|
34
|
+
function isHairlineLength(parsedLength, baseFontSize) {
|
|
35
|
+
if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '%') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', baseFontSize);
|
|
40
|
+
return px !== null && px > 0 && px <= 1;
|
|
41
|
+
}
|
|
42
|
+
|
|
28
43
|
function toPx(number, unit, baseFontSize) {
|
|
29
44
|
if (unit === '' || unit === 'px') {
|
|
30
45
|
return number;
|
|
@@ -168,6 +183,7 @@ function nearestScaleValues(target, scale) {
|
|
|
168
183
|
module.exports = {
|
|
169
184
|
formatLength,
|
|
170
185
|
fromPx,
|
|
186
|
+
isHairlineLength,
|
|
171
187
|
nearestScaleValues,
|
|
172
188
|
normalizeScale,
|
|
173
189
|
normalizeScaleByUnit,
|