stylelint-plugin-rhythmguard 1.5.0 → 1.6.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 +8 -0
- package/README.md +32 -5
- package/package.json +3 -3
- package/src/cli/audit.js +584 -100
- package/src/cli/index.js +2 -1
- package/src/eslint/rules/tailwind-class-use-scale.js +25 -120
- package/src/utils/tailwind-class-analysis.js +221 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,14 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.6.0] - 2026-05-19
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `rhythmguard audit` now reports design-system drift across CSS declarations and Tailwind arbitrary spacing class strings.
|
|
14
|
+
- Added `--format markdown` / `--markdown` audit output for PR-ready design-system health reports.
|
|
15
|
+
- Audit JSON now includes format version, CSS findings, Tailwind class-string findings, scan counts, top affected files, and summary totals while preserving existing top-level count fields.
|
|
16
|
+
|
|
9
17
|
## [1.4.2] - 2026-02-21
|
|
10
18
|
|
|
11
19
|
### Changed
|
package/README.md
CHANGED
|
@@ -77,6 +77,34 @@ This gives you spacing governance in both CSS files and JSX/TSX templates.
|
|
|
77
77
|
|
|
78
78
|
I built Rhythmguard after 20 years of watching teams ignore spacing scales and ship arbitrary pixel values everywhere.
|
|
79
79
|
|
|
80
|
+
## Try It in Your Browser
|
|
81
|
+
|
|
82
|
+
**[petrilahdelma.github.io/stylelint-plugin-rhythmguard](https://petrilahdelma.github.io/stylelint-plugin-rhythmguard/)** — paste CSS, see violations and token opportunities live. No install, no config.
|
|
83
|
+
|
|
84
|
+
## Audit Before You Enforce
|
|
85
|
+
|
|
86
|
+
Use the audit CLI to create a design-system drift report before turning rules into hard CI gates:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
npx rhythmguard audit ./src
|
|
90
|
+
npx rhythmguard audit ./src --format markdown
|
|
91
|
+
npx rhythmguard audit ./src --json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The report covers authored CSS declarations and Tailwind arbitrary spacing values in common template/source files. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
|
|
95
|
+
|
|
96
|
+
```md
|
|
97
|
+
# Rhythmguard Design-System Audit
|
|
98
|
+
|
|
99
|
+
| Metric | Value |
|
|
100
|
+
| --- | ---: |
|
|
101
|
+
| CSS files scanned | 47 |
|
|
102
|
+
| Template files scanned | 83 |
|
|
103
|
+
| Files with issues | 12 |
|
|
104
|
+
| Total findings | 52 |
|
|
105
|
+
| Scale cleanliness | 91% |
|
|
106
|
+
```
|
|
107
|
+
|
|
80
108
|
## Installation
|
|
81
109
|
|
|
82
110
|
```bash
|
|
@@ -180,6 +208,7 @@ Framework-specific setup for Vue, Lit, Astro, and SvelteKit: [`docs/FRAMEWORKS.m
|
|
|
180
208
|
## Comparison and Migration Recipes
|
|
181
209
|
|
|
182
210
|
- Side-by-side tool fit guide with migration snippets: [`docs/COMPARISON.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/COMPARISON.md)
|
|
211
|
+
- Audit 2.0 validation and roadmap: [`docs/AUDIT_2_VALIDATION.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/AUDIT_2_VALIDATION.md)
|
|
183
212
|
- Real-world before/after excerpts from public repos: [`docs/ADOPTION_DIFFS.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/ADOPTION_DIFFS.md)
|
|
184
213
|
- Distribution submissions to Stylelint discovery surfaces: [`docs/DISTRIBUTION.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/DISTRIBUTION.md)
|
|
185
214
|
|
|
@@ -502,13 +531,13 @@ The `tailwind` config preset automatically extracts spacing tokens from Tailwind
|
|
|
502
531
|
|
|
503
532
|
See [`docs/TAILWIND.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/TAILWIND.md) for full setup.
|
|
504
533
|
|
|
505
|
-
### What
|
|
534
|
+
### What the Stylelint layer does not cover
|
|
506
535
|
|
|
507
536
|
- Tailwind class strings in templates/JSX/TSX, for example:
|
|
508
537
|
- `class="p-4 gap-2"`
|
|
509
538
|
- `class="p-[13px] translate-y-[18px]"`
|
|
510
539
|
|
|
511
|
-
Those are not Stylelint declaration nodes, so they are outside Stylelint rule scope.
|
|
540
|
+
Those are not Stylelint declaration nodes, so they are outside Stylelint rule scope. Use the ESLint companion rule below for scale-aware class-string enforcement.
|
|
512
541
|
|
|
513
542
|
### Companion ESLint layer for class strings
|
|
514
543
|
|
|
@@ -562,7 +591,7 @@ Suggested setup:
|
|
|
562
591
|
Then pair with:
|
|
563
592
|
|
|
564
593
|
- `stylelint-plugin-rhythmguard/eslint` for arbitrary spacing class-string scale enforcement.
|
|
565
|
-
- `eslint-plugin-tailwindcss` for broader class-string linting and conventions.
|
|
594
|
+
- `eslint-plugin-tailwindcss` for broader class-string linting and conventions. If your policy is to ban every arbitrary value, enable its `tailwindcss/no-arbitrary-value` rule; use Rhythmguard when you want spacing-specific scale checks and nearest-value fixes.
|
|
566
595
|
- `prettier-plugin-tailwindcss` for deterministic class ordering.
|
|
567
596
|
|
|
568
597
|
Detailed setup reference: [`docs/TAILWIND.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/TAILWIND.md).
|
|
@@ -658,8 +687,6 @@ Detailed methodology and custom args are documented in [`docs/BENCHMARKING.md`](
|
|
|
658
687
|
## Article
|
|
659
688
|
|
|
660
689
|
- Dev.to: [Enforcing your spacing standards with Rhythmguard](https://dev.to/petrilahdelma/enforcing-your-spacing-standards-with-rhythmguard-a-custom-stylelint-plugin-1ojj)
|
|
661
|
-
- Original article update note (Feb 21, 2026): [`docs/DEVTO_ORIGINAL_UPDATE_NOTE_2026-02-21.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/DEVTO_ORIGINAL_UPDATE_NOTE_2026-02-21.md)
|
|
662
|
-
- Continuation draft (ready to publish): [`docs/DEVTO_CONTINUATION_2026-02-21.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/DEVTO_CONTINUATION_2026-02-21.md)
|
|
663
690
|
|
|
664
691
|
## Used by and Community Examples
|
|
665
692
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
|
|
5
5
|
"bin": {
|
|
6
|
-
"rhythmguard": "
|
|
6
|
+
"rhythmguard": "src/cli/index.js"
|
|
7
7
|
},
|
|
8
8
|
"keywords": [
|
|
9
9
|
"stylelint",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
},
|
|
100
100
|
"repository": {
|
|
101
101
|
"type": "git",
|
|
102
|
-
"url": "
|
|
102
|
+
"url": "https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard"
|
|
103
103
|
},
|
|
104
104
|
"homepage": "https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#readme",
|
|
105
105
|
"bugs": {
|
package/src/cli/audit.js
CHANGED
|
@@ -3,159 +3,643 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
+
const { formatLength } = require('../utils/length');
|
|
7
|
+
const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
|
|
8
|
+
|
|
6
9
|
const args = process.argv.slice(3);
|
|
7
|
-
const
|
|
8
|
-
|
|
10
|
+
const pluginPath = path.resolve(__dirname, '..', 'index.js');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
|
|
13
|
+
const DEFAULT_BASE_FONT_SIZE = 16;
|
|
14
|
+
const VALID_FORMATS = new Set(['text', 'json', 'markdown']);
|
|
15
|
+
const SKIP_DIRS = new Set([
|
|
16
|
+
'.git',
|
|
17
|
+
'.next',
|
|
18
|
+
'.nuxt',
|
|
19
|
+
'.omx',
|
|
20
|
+
'build',
|
|
21
|
+
'coverage',
|
|
22
|
+
'dist',
|
|
23
|
+
'node_modules',
|
|
24
|
+
'out',
|
|
25
|
+
]);
|
|
26
|
+
const TEMPLATE_EXTENSIONS = new Set([
|
|
27
|
+
'.astro',
|
|
28
|
+
'.cjs',
|
|
29
|
+
'.js',
|
|
30
|
+
'.jsx',
|
|
31
|
+
'.mjs',
|
|
32
|
+
'.svelte',
|
|
33
|
+
'.ts',
|
|
34
|
+
'.tsx',
|
|
35
|
+
'.vue',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const HELP = `Usage: rhythmguard audit <dir> [options]
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
--format <text|json|markdown> Output format (default: text)
|
|
42
|
+
--json Alias for --format json
|
|
43
|
+
--markdown Alias for --format markdown
|
|
44
|
+
--scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
|
|
45
|
+
--base-font-size <number> px base for rem/em conversion (default: 16)
|
|
46
|
+
`;
|
|
47
|
+
|
|
48
|
+
function parseArgs(argv) {
|
|
49
|
+
const parsed = {
|
|
50
|
+
baseFontSize: DEFAULT_BASE_FONT_SIZE,
|
|
51
|
+
dir: null,
|
|
52
|
+
format: 'text',
|
|
53
|
+
scale: DEFAULT_SCALE,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
for (let index = 0; index < argv.length; index++) {
|
|
57
|
+
const arg = argv[index];
|
|
58
|
+
|
|
59
|
+
if (arg === '--help' || arg === '-h') {
|
|
60
|
+
parsed.help = true;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (arg === '--json') {
|
|
65
|
+
parsed.format = 'json';
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (arg === '--markdown') {
|
|
70
|
+
parsed.format = 'markdown';
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (arg === '--format') {
|
|
75
|
+
parsed.format = String(argv[++index] || '').toLowerCase();
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (arg.startsWith('--format=')) {
|
|
80
|
+
parsed.format = arg.slice('--format='.length).toLowerCase();
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (arg === '--scale') {
|
|
85
|
+
parsed.scale = parseScale(argv[++index]);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (arg.startsWith('--scale=')) {
|
|
90
|
+
parsed.scale = parseScale(arg.slice('--scale='.length));
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (arg === '--base-font-size') {
|
|
95
|
+
parsed.baseFontSize = parseBaseFontSize(argv[++index]);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (arg.startsWith('--base-font-size=')) {
|
|
100
|
+
parsed.baseFontSize = parseBaseFontSize(arg.slice('--base-font-size='.length));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!arg.startsWith('-') && !parsed.dir) {
|
|
105
|
+
parsed.dir = arg;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!VALID_FORMATS.has(parsed.format)) {
|
|
113
|
+
throw new Error(`Invalid format "${parsed.format}". Expected text, json, or markdown.`);
|
|
114
|
+
}
|
|
9
115
|
|
|
10
|
-
|
|
11
|
-
process.stderr.write('Usage: rhythmguard audit <dir> [--json]\n');
|
|
12
|
-
process.exit(1);
|
|
116
|
+
return parsed;
|
|
13
117
|
}
|
|
14
118
|
|
|
15
|
-
|
|
119
|
+
function parseScale(raw) {
|
|
120
|
+
if (!raw) {
|
|
121
|
+
throw new Error('Missing value for --scale.');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const scale = raw.split(',')
|
|
125
|
+
.map((part) => part.trim())
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.map((part) => {
|
|
128
|
+
const numeric = Number(part);
|
|
129
|
+
return Number.isFinite(numeric) && !/[a-z%]/i.test(part) ? numeric : part;
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (scale.length === 0) {
|
|
133
|
+
throw new Error('Scale must include at least one value.');
|
|
134
|
+
}
|
|
16
135
|
|
|
17
|
-
|
|
18
|
-
process.stderr.write(`Directory not found: ${dir}\n`);
|
|
19
|
-
process.exit(1);
|
|
136
|
+
return scale;
|
|
20
137
|
}
|
|
21
138
|
|
|
22
|
-
|
|
139
|
+
function parseBaseFontSize(raw) {
|
|
140
|
+
const value = Number(raw);
|
|
141
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
142
|
+
throw new Error('--base-font-size must be a positive number.');
|
|
143
|
+
}
|
|
23
144
|
|
|
24
|
-
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
25
147
|
|
|
26
|
-
|
|
27
|
-
|
|
148
|
+
function assertDirectory(dir) {
|
|
149
|
+
if (!dir) {
|
|
150
|
+
process.stderr.write(HELP);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
28
153
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
files: GLOB_PATTERN,
|
|
33
|
-
config: {
|
|
34
|
-
plugins: [pluginPath],
|
|
35
|
-
rules: {
|
|
36
|
-
'rhythmguard/use-scale': [true, { severity: 'warning' }],
|
|
37
|
-
'rhythmguard/prefer-token': [
|
|
38
|
-
true,
|
|
39
|
-
{
|
|
40
|
-
tokenMapFromCssCustomProperties: true,
|
|
41
|
-
severity: 'warning',
|
|
42
|
-
},
|
|
43
|
-
],
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
} catch (err) {
|
|
48
|
-
process.stderr.write(`Lint error: ${err.message}\n`);
|
|
154
|
+
const resolvedDir = path.resolve(dir);
|
|
155
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
156
|
+
process.stderr.write(`Directory not found: ${dir}\n`);
|
|
49
157
|
process.exit(1);
|
|
50
158
|
}
|
|
51
159
|
|
|
52
|
-
|
|
53
|
-
|
|
160
|
+
return resolvedDir;
|
|
161
|
+
}
|
|
54
162
|
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
let filesWithIssues = 0;
|
|
58
|
-
let totalWarnings = 0;
|
|
163
|
+
function walkFiles(rootDir) {
|
|
164
|
+
const files = [];
|
|
59
165
|
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
166
|
+
function walk(currentDir) {
|
|
167
|
+
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
|
168
|
+
if (entry.isDirectory()) {
|
|
169
|
+
if (!SKIP_DIRS.has(entry.name)) {
|
|
170
|
+
walk(path.join(currentDir, entry.name));
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (entry.isFile()) {
|
|
176
|
+
files.push(path.join(currentDir, entry.name));
|
|
177
|
+
}
|
|
64
178
|
}
|
|
179
|
+
}
|
|
65
180
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
181
|
+
walk(rootDir);
|
|
182
|
+
return files;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isCssFile(filePath) {
|
|
186
|
+
return filePath.endsWith('.css');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isTemplateFile(filePath) {
|
|
190
|
+
return TEMPLATE_EXTENSIONS.has(path.extname(filePath));
|
|
191
|
+
}
|
|
69
192
|
|
|
70
|
-
|
|
71
|
-
|
|
193
|
+
async function runStylelintAudit(cssFiles, options) {
|
|
194
|
+
if (cssFiles.length === 0) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const { default: stylelint } = await import('stylelint');
|
|
199
|
+
|
|
200
|
+
const result = await stylelint.lint({
|
|
201
|
+
files: cssFiles,
|
|
202
|
+
config: {
|
|
203
|
+
plugins: [pluginPath],
|
|
204
|
+
rules: {
|
|
205
|
+
'rhythmguard/use-scale': [
|
|
206
|
+
true,
|
|
207
|
+
{
|
|
208
|
+
baseFontSize: options.baseFontSize,
|
|
209
|
+
scale: options.scale,
|
|
210
|
+
severity: 'warning',
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
'rhythmguard/prefer-token': [
|
|
214
|
+
true,
|
|
215
|
+
{
|
|
216
|
+
baseFontSize: options.baseFontSize,
|
|
217
|
+
scale: options.scale,
|
|
218
|
+
severity: 'warning',
|
|
219
|
+
tokenMapFromCssCustomProperties: true,
|
|
220
|
+
tokenPattern: '^--spac(e|ing)-',
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return result.results || [];
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function collectCssFindings(fileResults) {
|
|
231
|
+
const findings = [];
|
|
232
|
+
|
|
233
|
+
for (const fileResult of fileResults) {
|
|
234
|
+
for (const warning of fileResult.warnings || []) {
|
|
235
|
+
const text = warning.text || '';
|
|
72
236
|
const offScaleMatch = text.match(
|
|
73
|
-
/Unexpected off-scale value "([^"]+)"/,
|
|
237
|
+
/Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
|
|
74
238
|
);
|
|
75
|
-
if (offScaleMatch) {
|
|
76
|
-
const value = offScaleMatch[1];
|
|
77
|
-
offScaleValues[value] = (offScaleValues[value] || 0) + 1;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Extract token opportunities from prefer-token messages
|
|
81
|
-
// Format: Unexpected raw scale value "16px". Use design tokens for scale decisions.
|
|
82
239
|
const tokenMatch = text.match(
|
|
83
240
|
/Unexpected raw scale value "([^"]+)"/,
|
|
84
241
|
);
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
242
|
+
|
|
243
|
+
findings.push({
|
|
244
|
+
column: warning.column || 1,
|
|
245
|
+
file: formatPath(fileResult.source),
|
|
246
|
+
line: warning.line || 1,
|
|
247
|
+
rule: warning.rule || 'rhythmguard',
|
|
248
|
+
text,
|
|
249
|
+
type: tokenMatch ? 'token-opportunity' : 'off-scale',
|
|
250
|
+
value: tokenMatch ? tokenMatch[1] : offScaleMatch ? offScaleMatch[1] : null,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return findings;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function collectTailwindFindings(templateFiles, options) {
|
|
259
|
+
const analyzer = createTailwindClassAnalyzer(options);
|
|
260
|
+
const findings = [];
|
|
261
|
+
|
|
262
|
+
for (const filePath of templateFiles) {
|
|
263
|
+
let source = '';
|
|
264
|
+
try {
|
|
265
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
266
|
+
} catch {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const lineStarts = getLineStarts(source);
|
|
271
|
+
|
|
272
|
+
for (const literal of findStringLiterals(source)) {
|
|
273
|
+
for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
|
|
274
|
+
const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
|
|
275
|
+
findings.push({
|
|
276
|
+
column: position.column,
|
|
277
|
+
file: formatPath(filePath),
|
|
278
|
+
fixedToken: analysis.fixedToken,
|
|
279
|
+
line: position.line,
|
|
280
|
+
nearest: analysis.nearest
|
|
281
|
+
? {
|
|
282
|
+
lower: formatLength(analysis.nearest.lower, 'px'),
|
|
283
|
+
upper: formatLength(analysis.nearest.upper, 'px'),
|
|
284
|
+
}
|
|
285
|
+
: null,
|
|
286
|
+
rawValue: analysis.rawValue,
|
|
287
|
+
rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
|
|
288
|
+
text: analysis.reason === 'negative'
|
|
289
|
+
? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
|
|
290
|
+
: `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
|
|
291
|
+
token: segment.token,
|
|
292
|
+
type: 'tailwind-arbitrary-spacing',
|
|
293
|
+
utility: analysis.utility,
|
|
294
|
+
});
|
|
88
295
|
}
|
|
89
296
|
}
|
|
90
297
|
}
|
|
91
298
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.slice(0, 10);
|
|
299
|
+
return findings;
|
|
300
|
+
}
|
|
95
301
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
302
|
+
function findStringLiterals(source) {
|
|
303
|
+
const literals = [];
|
|
304
|
+
const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
|
|
305
|
+
let match;
|
|
306
|
+
|
|
307
|
+
while ((match = literalPattern.exec(source)) !== null) {
|
|
308
|
+
literals.push({
|
|
309
|
+
quote: match[1],
|
|
310
|
+
value: match[2],
|
|
311
|
+
valueStart: match.index + 1,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return literals;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function getLineStarts(source) {
|
|
319
|
+
const starts = [0];
|
|
320
|
+
|
|
321
|
+
for (let index = 0; index < source.length; index++) {
|
|
322
|
+
if (source[index] === '\n') {
|
|
323
|
+
starts.push(index + 1);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return starts;
|
|
328
|
+
}
|
|
99
329
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
330
|
+
function offsetToLineColumn(lineStarts, offset) {
|
|
331
|
+
let low = 0;
|
|
332
|
+
let high = lineStarts.length - 1;
|
|
333
|
+
|
|
334
|
+
while (low <= high) {
|
|
335
|
+
const mid = Math.floor((low + high) / 2);
|
|
336
|
+
if (lineStarts[mid] <= offset) {
|
|
337
|
+
low = mid + 1;
|
|
338
|
+
} else {
|
|
339
|
+
high = mid - 1;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const lineIndex = Math.max(0, high);
|
|
344
|
+
return {
|
|
345
|
+
column: offset - lineStarts[lineIndex] + 1,
|
|
346
|
+
line: lineIndex + 1,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function buildReport({
|
|
351
|
+
cssFiles,
|
|
352
|
+
cssFindings,
|
|
353
|
+
dir,
|
|
354
|
+
templateFiles,
|
|
355
|
+
tailwindFindings,
|
|
356
|
+
}) {
|
|
357
|
+
const offScaleValues = countByValue(cssFindings
|
|
358
|
+
.filter((finding) => finding.type === 'off-scale' && finding.value)
|
|
359
|
+
.map((finding) => finding.value));
|
|
360
|
+
const tokenOpportunities = countByValue(cssFindings
|
|
361
|
+
.filter((finding) => finding.type === 'token-opportunity' && finding.value)
|
|
362
|
+
.map((finding) => finding.value));
|
|
363
|
+
const tailwindArbitraryValues = countByValue(tailwindFindings
|
|
364
|
+
.map((finding) => finding.rawValue));
|
|
365
|
+
const issueFiles = new Set([
|
|
366
|
+
...cssFindings.map((finding) => finding.file),
|
|
367
|
+
...tailwindFindings.map((finding) => finding.file),
|
|
368
|
+
]);
|
|
369
|
+
const topAffectedFiles = sortCountMap(countByValue([
|
|
370
|
+
...cssFindings.map((finding) => finding.file),
|
|
371
|
+
...tailwindFindings.map((finding) => finding.file),
|
|
372
|
+
])).slice(0, 10);
|
|
373
|
+
|
|
374
|
+
const totalFiles = cssFiles.length + templateFiles.length;
|
|
375
|
+
const totalWarnings = cssFindings.length + tailwindFindings.length;
|
|
376
|
+
const filesWithIssues = issueFiles.size;
|
|
377
|
+
const scaleCleanliness = totalFiles > 0
|
|
378
|
+
? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
|
|
379
|
+
: 100;
|
|
380
|
+
|
|
381
|
+
return {
|
|
382
|
+
cssFilesScanned: cssFiles.length,
|
|
383
|
+
directory: dir,
|
|
384
|
+
filesWithIssues,
|
|
385
|
+
findings: {
|
|
386
|
+
css: cssFindings,
|
|
387
|
+
tailwind: tailwindFindings,
|
|
388
|
+
},
|
|
389
|
+
formatVersion: 2,
|
|
390
|
+
offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
|
|
391
|
+
scaleCleanliness,
|
|
392
|
+
scanned: {
|
|
393
|
+
cssFiles: cssFiles.length,
|
|
394
|
+
templateFiles: templateFiles.length,
|
|
103
395
|
totalFiles,
|
|
396
|
+
},
|
|
397
|
+
summary: {
|
|
398
|
+
cssWarnings: cssFindings.length,
|
|
104
399
|
filesWithIssues,
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
tokenOpportunities:
|
|
108
|
-
|
|
109
|
-
|
|
400
|
+
scaleCleanliness,
|
|
401
|
+
tailwindArbitrarySpacing: tailwindFindings.length,
|
|
402
|
+
tokenOpportunities: sumCounts(tokenOpportunities),
|
|
403
|
+
totalFindings: totalWarnings,
|
|
404
|
+
},
|
|
405
|
+
tailwindArbitraryValues: Object.fromEntries(sortCountMap(tailwindArbitraryValues).slice(0, 10)),
|
|
406
|
+
templateFilesScanned: templateFiles.length,
|
|
407
|
+
tokenOpportunities: Object.fromEntries(sortCountMap(tokenOpportunities).slice(0, 10)),
|
|
408
|
+
topAffectedFiles: topAffectedFiles.map(([file, count]) => ({ count, file })),
|
|
409
|
+
totalFiles,
|
|
410
|
+
totalWarnings,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function countByValue(values) {
|
|
415
|
+
const counts = {};
|
|
416
|
+
|
|
417
|
+
for (const value of values) {
|
|
418
|
+
if (value) {
|
|
419
|
+
counts[value] = (counts[value] || 0) + 1;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return counts;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sortCountMap(counts) {
|
|
427
|
+
return Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function sumCounts(counts) {
|
|
431
|
+
return Object.values(counts).reduce((total, count) => total + count, 0);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function renderText(report) {
|
|
435
|
+
const lines = [
|
|
436
|
+
'',
|
|
437
|
+
' ┌─ RHYTHMGUARD DESIGN-SYSTEM AUDIT ─────────────────────────┐',
|
|
438
|
+
` │ ${report.directory.padEnd(58)}│`,
|
|
439
|
+
' └─────────────────────────────────────────────────────────────┘',
|
|
440
|
+
'',
|
|
441
|
+
` CSS files scanned ${String(report.cssFilesScanned).padStart(4)}`,
|
|
442
|
+
` Template files scanned ${String(report.templateFilesScanned).padStart(4)}`,
|
|
443
|
+
` Files with issues ${String(report.filesWithIssues).padStart(4)}`,
|
|
444
|
+
` Scale cleanliness ${scoreBar(report.scaleCleanliness)} ${report.scaleCleanliness}%`,
|
|
445
|
+
'',
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
|
|
449
|
+
appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
|
|
450
|
+
appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
|
|
451
|
+
|
|
452
|
+
if (report.topAffectedFiles.length > 0) {
|
|
453
|
+
lines.push(' ── TOP AFFECTED FILES ──');
|
|
454
|
+
lines.push('');
|
|
455
|
+
const maxCount = Math.max(...report.topAffectedFiles.map(({ count }) => count));
|
|
456
|
+
for (const { file, count } of report.topAffectedFiles) {
|
|
457
|
+
lines.push(` ${truncate(file, 34).padEnd(36)} ${histBar(count, maxCount)} ${count}`);
|
|
458
|
+
}
|
|
459
|
+
lines.push('');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (report.totalWarnings === 0) {
|
|
463
|
+
lines.push(' ✓ No issues found. Your design-system rhythm is clean.');
|
|
464
|
+
} else {
|
|
465
|
+
lines.push(' → Run "npx stylelint --fix" for CSS declaration fixes.');
|
|
466
|
+
lines.push(' → Use "npx rhythmguard audit ./src --format markdown" for PR/design review.');
|
|
467
|
+
}
|
|
468
|
+
lines.push('');
|
|
469
|
+
|
|
470
|
+
return `${lines.join('\n')}\n`;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function appendHistogram(lines, title, counts) {
|
|
474
|
+
const entries = sortCountMap(counts);
|
|
475
|
+
const total = sumCounts(counts);
|
|
476
|
+
|
|
477
|
+
if (entries.length === 0) {
|
|
110
478
|
return;
|
|
111
479
|
}
|
|
112
480
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
481
|
+
lines.push(` ── ${title} ── ${total} total`);
|
|
482
|
+
lines.push('');
|
|
483
|
+
const maxCount = Math.max(...entries.map(([, count]) => count));
|
|
484
|
+
for (const [value, count] of entries) {
|
|
485
|
+
lines.push(` ${value.padEnd(14)} ${histBar(count, maxCount)} ${count}`);
|
|
486
|
+
}
|
|
487
|
+
lines.push('');
|
|
488
|
+
}
|
|
117
489
|
|
|
490
|
+
function renderMarkdown(report) {
|
|
118
491
|
const lines = [
|
|
492
|
+
'# Rhythmguard Design-System Audit',
|
|
493
|
+
'',
|
|
494
|
+
`Directory: \`${report.directory}\``,
|
|
119
495
|
'',
|
|
120
|
-
|
|
496
|
+
'## Summary',
|
|
121
497
|
'',
|
|
122
|
-
|
|
123
|
-
|
|
498
|
+
'| Metric | Value |',
|
|
499
|
+
'| --- | ---: |',
|
|
500
|
+
`| CSS files scanned | ${report.cssFilesScanned} |`,
|
|
501
|
+
`| Template files scanned | ${report.templateFilesScanned} |`,
|
|
502
|
+
`| Files with issues | ${report.filesWithIssues} |`,
|
|
503
|
+
`| Total findings | ${report.totalWarnings} |`,
|
|
504
|
+
`| Scale cleanliness | ${report.scaleCleanliness}% |`,
|
|
124
505
|
'',
|
|
125
506
|
];
|
|
126
507
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
lines.push(
|
|
133
|
-
|
|
134
|
-
|
|
508
|
+
appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
|
|
509
|
+
appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
|
|
510
|
+
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
511
|
+
|
|
512
|
+
if (report.topAffectedFiles.length > 0) {
|
|
513
|
+
lines.push('## Top Affected Files');
|
|
514
|
+
lines.push('');
|
|
515
|
+
lines.push('| File | Findings |');
|
|
516
|
+
lines.push('| --- | ---: |');
|
|
517
|
+
for (const { file, count } of report.topAffectedFiles) {
|
|
518
|
+
lines.push(`| \`${escapeMarkdown(file)}\` | ${count} |`);
|
|
135
519
|
}
|
|
136
520
|
lines.push('');
|
|
137
521
|
}
|
|
138
522
|
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
lines.push(` ${value.padEnd(10)} ×${count}`);
|
|
523
|
+
if (report.findings.tailwind.length > 0) {
|
|
524
|
+
lines.push('## Tailwind Examples');
|
|
525
|
+
lines.push('');
|
|
526
|
+
lines.push('| File | Class | Suggested class |');
|
|
527
|
+
lines.push('| --- | --- | --- |');
|
|
528
|
+
for (const finding of report.findings.tailwind.slice(0, 10)) {
|
|
529
|
+
lines.push(`| \`${escapeMarkdown(`${finding.file}:${finding.line}`)}\` | \`${escapeMarkdown(finding.token)}\` | \`${escapeMarkdown(finding.fixedToken || 'n/a')}\` |`);
|
|
147
530
|
}
|
|
148
531
|
lines.push('');
|
|
149
532
|
}
|
|
150
533
|
|
|
151
|
-
|
|
152
|
-
|
|
534
|
+
lines.push('## Recommended Next Actions');
|
|
535
|
+
lines.push('');
|
|
536
|
+
if (report.totalWarnings === 0) {
|
|
537
|
+
lines.push('- Keep Rhythmguard in CI to prevent new drift.');
|
|
153
538
|
} else {
|
|
154
|
-
lines.push('Run
|
|
539
|
+
lines.push('- Run deterministic autofix for CSS declarations where appropriate.');
|
|
540
|
+
lines.push('- Review Tailwind arbitrary spacing values with UX/design-system owners.');
|
|
541
|
+
lines.push('- Convert repeated raw on-scale values into existing design tokens.');
|
|
542
|
+
}
|
|
543
|
+
lines.push('');
|
|
544
|
+
|
|
545
|
+
return `${lines.join('\n')}\n`;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function appendMarkdownCounts(lines, title, counts) {
|
|
549
|
+
const entries = sortCountMap(counts);
|
|
550
|
+
|
|
551
|
+
if (entries.length === 0) {
|
|
552
|
+
return;
|
|
155
553
|
}
|
|
156
554
|
|
|
555
|
+
lines.push(`## ${title}`);
|
|
556
|
+
lines.push('');
|
|
557
|
+
lines.push('| Value | Count |');
|
|
558
|
+
lines.push('| --- | ---: |');
|
|
559
|
+
for (const [value, count] of entries) {
|
|
560
|
+
lines.push(`| \`${escapeMarkdown(value)}\` | ${count} |`);
|
|
561
|
+
}
|
|
157
562
|
lines.push('');
|
|
158
|
-
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function histBar(count, maxCount) {
|
|
566
|
+
const width = 30;
|
|
567
|
+
const filled = Math.round((count / maxCount) * width);
|
|
568
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function scoreBar(pct) {
|
|
572
|
+
const width = 20;
|
|
573
|
+
const filled = Math.round((pct / 100) * width);
|
|
574
|
+
return '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function truncate(value, maxLength) {
|
|
578
|
+
if (value.length <= maxLength) {
|
|
579
|
+
return value;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
return `…${value.slice(value.length - maxLength + 1)}`;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function escapeMarkdown(value) {
|
|
586
|
+
return String(value).replace(/\|/g, '\\|').replace(/`/g, '\\`');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function formatPath(filePath) {
|
|
590
|
+
return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function run() {
|
|
594
|
+
let parsed;
|
|
595
|
+
try {
|
|
596
|
+
parsed = parseArgs(args);
|
|
597
|
+
} catch (err) {
|
|
598
|
+
process.stderr.write(`${err.message}\n\n${HELP}`);
|
|
599
|
+
process.exit(1);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (parsed.help) {
|
|
603
|
+
process.stdout.write(HELP);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const resolvedDir = assertDirectory(parsed.dir);
|
|
608
|
+
const allFiles = walkFiles(resolvedDir);
|
|
609
|
+
const cssFiles = allFiles.filter(isCssFile);
|
|
610
|
+
const templateFiles = allFiles.filter(isTemplateFile);
|
|
611
|
+
const options = {
|
|
612
|
+
baseFontSize: parsed.baseFontSize,
|
|
613
|
+
scale: parsed.scale,
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
let cssResults;
|
|
617
|
+
try {
|
|
618
|
+
cssResults = await runStylelintAudit(cssFiles, options);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
process.stderr.write(`Lint error: ${err.message}\n`);
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const report = buildReport({
|
|
625
|
+
cssFiles,
|
|
626
|
+
cssFindings: collectCssFindings(cssResults),
|
|
627
|
+
dir: parsed.dir,
|
|
628
|
+
tailwindFindings: collectTailwindFindings(templateFiles, options),
|
|
629
|
+
templateFiles,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
if (parsed.format === 'json') {
|
|
633
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (parsed.format === 'markdown') {
|
|
638
|
+
process.stdout.write(renderMarkdown(report));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
process.stdout.write(renderText(report));
|
|
159
643
|
}
|
|
160
644
|
|
|
161
645
|
run();
|
package/src/cli/index.js
CHANGED
|
@@ -6,7 +6,7 @@ const command = process.argv[2];
|
|
|
6
6
|
const HELP = `Usage: rhythmguard <command>
|
|
7
7
|
|
|
8
8
|
Commands:
|
|
9
|
-
audit <dir> Report
|
|
9
|
+
audit <dir> Report design-system drift across CSS and Tailwind class strings
|
|
10
10
|
init Scaffold a Rhythmguard config for your project
|
|
11
11
|
doctor Validate your Rhythmguard setup
|
|
12
12
|
|
|
@@ -16,6 +16,7 @@ Options:
|
|
|
16
16
|
Examples:
|
|
17
17
|
npx rhythmguard audit ./src
|
|
18
18
|
npx rhythmguard audit ./src --json
|
|
19
|
+
npx rhythmguard audit ./src --format markdown
|
|
19
20
|
npx rhythmguard init
|
|
20
21
|
npx rhythmguard doctor
|
|
21
22
|
`;
|
|
@@ -1,112 +1,11 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
|
|
5
|
-
nearestScaleValues,
|
|
6
|
-
normalizeScale,
|
|
7
|
-
numbersEqual,
|
|
8
|
-
parseLengthToken,
|
|
9
|
-
toPx,
|
|
10
|
-
} = require('../../utils/length');
|
|
3
|
+
const { formatLength } = require('../../utils/length');
|
|
4
|
+
const { createTailwindClassAnalyzer } = require('../../utils/tailwind-class-analysis');
|
|
11
5
|
|
|
12
6
|
const RULE_NAME = 'tailwind-class-use-scale';
|
|
13
7
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
function getOptions(context) {
|
|
17
|
-
const option = context.options && context.options[0] ? context.options[0] : {};
|
|
18
|
-
|
|
19
|
-
return {
|
|
20
|
-
allowNegative: option.allowNegative !== false,
|
|
21
|
-
baseFontSize:
|
|
22
|
-
typeof option.baseFontSize === 'number' &&
|
|
23
|
-
Number.isFinite(option.baseFontSize) &&
|
|
24
|
-
option.baseFontSize > 0
|
|
25
|
-
? option.baseFontSize
|
|
26
|
-
: 16,
|
|
27
|
-
scale: Array.isArray(option.scale)
|
|
28
|
-
? option.scale
|
|
29
|
-
: [0, 4, 8, 12, 16, 24, 32],
|
|
30
|
-
units: Array.isArray(option.units)
|
|
31
|
-
? option.units.map((unit) => String(unit).toLowerCase())
|
|
32
|
-
: ['px', 'rem', 'em'],
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function findClassSegments(value) {
|
|
37
|
-
const segments = [];
|
|
38
|
-
const tokenRegex = /\S+/g;
|
|
39
|
-
let match;
|
|
40
|
-
|
|
41
|
-
while ((match = tokenRegex.exec(value)) !== null) {
|
|
42
|
-
segments.push({
|
|
43
|
-
start: match.index,
|
|
44
|
-
token: match[0],
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
return segments;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function analyzeToken(token, options, scalePx) {
|
|
52
|
-
const match = token.match(ARBITRARY_SPACING_CLASS);
|
|
53
|
-
if (!match || !match.groups) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const parsedLength = parseLengthToken(match.groups.rawValue);
|
|
58
|
-
if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '') {
|
|
59
|
-
return null;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
if (!options.allowNegative && parsedLength.number < 0) {
|
|
63
|
-
return {
|
|
64
|
-
nearest: null,
|
|
65
|
-
parsedLength,
|
|
66
|
-
reason: 'negative',
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!options.units.includes(parsedLength.unit)) {
|
|
71
|
-
return null;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
const pxValue = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
|
|
75
|
-
if (pxValue === null) {
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const isOnScale = scalePx.some((entry) => numbersEqual(entry, pxValue));
|
|
80
|
-
if (isOnScale) {
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const nearest = nearestScaleValues(pxValue, scalePx);
|
|
85
|
-
if (!nearest) {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const signedNearest = parsedLength.number < 0
|
|
90
|
-
? -Math.abs(nearest.nearest)
|
|
91
|
-
: nearest.nearest;
|
|
92
|
-
|
|
93
|
-
const replacementValuePx = signedNearest;
|
|
94
|
-
const replacementNumber = parsedLength.unit === 'px'
|
|
95
|
-
? replacementValuePx
|
|
96
|
-
: replacementValuePx / options.baseFontSize;
|
|
97
|
-
|
|
98
|
-
const replacementValue = formatLength(replacementNumber, parsedLength.unit);
|
|
99
|
-
const fixedToken = token.replace(match.groups.rawValue, replacementValue);
|
|
100
|
-
|
|
101
|
-
return {
|
|
102
|
-
fixedToken,
|
|
103
|
-
nearest,
|
|
104
|
-
parsedLength,
|
|
105
|
-
reason: 'off-scale',
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function maybeCheckNodeText(node, sourceCode, context, options, scalePx, allowFix) {
|
|
8
|
+
function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
110
9
|
const rawText = sourceCode.getText(node);
|
|
111
10
|
let value = null;
|
|
112
11
|
let quote = null;
|
|
@@ -124,16 +23,27 @@ function maybeCheckNodeText(node, sourceCode, context, options, scalePx, allowFi
|
|
|
124
23
|
return;
|
|
125
24
|
}
|
|
126
25
|
|
|
127
|
-
const
|
|
128
|
-
if (
|
|
26
|
+
const findings = analyzer.analyzeClassString(value);
|
|
27
|
+
if (findings.length === 0) {
|
|
129
28
|
return;
|
|
130
29
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
30
|
+
|
|
31
|
+
let fixedValue = value;
|
|
32
|
+
let fixedValueOffset = 0;
|
|
33
|
+
|
|
34
|
+
for (const { analysis, segment } of findings) {
|
|
35
|
+
if (allowFix && analysis.reason !== 'negative' && node.type === 'Literal') {
|
|
36
|
+
const replacementStart = segment.start + fixedValueOffset;
|
|
37
|
+
fixedValue = `${fixedValue.slice(0, replacementStart)}${analysis.fixedToken}${fixedValue.slice(replacementStart + segment.token.length)}`;
|
|
38
|
+
fixedValueOffset += analysis.fixedToken.length - segment.token.length;
|
|
135
39
|
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fixedText = allowFix && node.type === 'Literal' && fixedValue !== value
|
|
43
|
+
? `${quote}${fixedValue.replace(new RegExp(quote, 'g'), `\\${quote}`)}${quote}`
|
|
44
|
+
: null;
|
|
136
45
|
|
|
46
|
+
for (const { analysis, segment } of findings) {
|
|
137
47
|
const lower = analysis.nearest
|
|
138
48
|
? formatLength(analysis.nearest.lower, 'px')
|
|
139
49
|
: 'n/a';
|
|
@@ -147,12 +57,8 @@ function maybeCheckNodeText(node, sourceCode, context, options, scalePx, allowFi
|
|
|
147
57
|
: `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values (nearest: ${lower} or ${upper}).`,
|
|
148
58
|
node,
|
|
149
59
|
fix:
|
|
150
|
-
|
|
151
|
-
? (fixer) =>
|
|
152
|
-
const nextValue = `${value.slice(0, segment.start)}${analysis.fixedToken}${value.slice(segment.start + segment.token.length)}`;
|
|
153
|
-
const escaped = nextValue.replace(new RegExp(quote, 'g'), `\\${quote}`);
|
|
154
|
-
return fixer.replaceText(node, `${quote}${escaped}${quote}`);
|
|
155
|
-
}
|
|
60
|
+
fixedText && analysis.reason !== 'negative'
|
|
61
|
+
? (fixer) => fixer.replaceText(node, fixedText)
|
|
156
62
|
: null,
|
|
157
63
|
});
|
|
158
64
|
}
|
|
@@ -189,16 +95,15 @@ module.exports = {
|
|
|
189
95
|
],
|
|
190
96
|
},
|
|
191
97
|
create(context) {
|
|
192
|
-
const
|
|
193
|
-
const scalePx = normalizeScale(options.scale, options.baseFontSize);
|
|
98
|
+
const analyzer = createTailwindClassAnalyzer(context.options && context.options[0]);
|
|
194
99
|
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
195
100
|
|
|
196
101
|
return {
|
|
197
102
|
Literal(node) {
|
|
198
|
-
maybeCheckNodeText(node, sourceCode, context,
|
|
103
|
+
maybeCheckNodeText(node, sourceCode, context, analyzer, true);
|
|
199
104
|
},
|
|
200
105
|
TemplateElement(node) {
|
|
201
|
-
maybeCheckNodeText(node, sourceCode, context,
|
|
106
|
+
maybeCheckNodeText(node, sourceCode, context, analyzer, false);
|
|
202
107
|
},
|
|
203
108
|
};
|
|
204
109
|
},
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
formatLength,
|
|
5
|
+
nearestScaleValues,
|
|
6
|
+
normalizeScale,
|
|
7
|
+
numbersEqual,
|
|
8
|
+
parseLengthToken,
|
|
9
|
+
toPx,
|
|
10
|
+
} = require('./length');
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
|
|
13
|
+
const DEFAULT_UNITS = ['px', 'rem', 'em'];
|
|
14
|
+
|
|
15
|
+
const ARBITRARY_SPACING_CLASS = /^(?<utility>-?(?:m(?:[trblxy])?|p(?:[trblxy])?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|scroll-(?:m|p)(?:[trblxy])?))-\[(?<rawValue>[^\]]+)\]$/;
|
|
16
|
+
|
|
17
|
+
function normalizeTailwindClassOptions(option = {}) {
|
|
18
|
+
return {
|
|
19
|
+
allowNegative: option.allowNegative !== false,
|
|
20
|
+
baseFontSize:
|
|
21
|
+
typeof option.baseFontSize === 'number' &&
|
|
22
|
+
Number.isFinite(option.baseFontSize) &&
|
|
23
|
+
option.baseFontSize > 0
|
|
24
|
+
? option.baseFontSize
|
|
25
|
+
: 16,
|
|
26
|
+
scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
|
|
27
|
+
units: Array.isArray(option.units)
|
|
28
|
+
? option.units.map((unit) => String(unit).toLowerCase())
|
|
29
|
+
: DEFAULT_UNITS,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function findClassSegments(value) {
|
|
34
|
+
const segments = [];
|
|
35
|
+
const tokenRegex = /\S+/g;
|
|
36
|
+
let match;
|
|
37
|
+
|
|
38
|
+
while ((match = tokenRegex.exec(value)) !== null) {
|
|
39
|
+
segments.push({
|
|
40
|
+
start: match.index,
|
|
41
|
+
token: match[0],
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return segments;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function findLastVariantSeparator(token) {
|
|
49
|
+
let bracketDepth = 0;
|
|
50
|
+
let separatorIndex = -1;
|
|
51
|
+
|
|
52
|
+
for (let index = 0; index < token.length; index++) {
|
|
53
|
+
const character = token[index];
|
|
54
|
+
|
|
55
|
+
if (character === '[') {
|
|
56
|
+
bracketDepth++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (character === ']' && bracketDepth > 0) {
|
|
61
|
+
bracketDepth--;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (character === ':' && bracketDepth === 0) {
|
|
66
|
+
separatorIndex = index;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return separatorIndex;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseClassToken(token) {
|
|
74
|
+
const separatorIndex = findLastVariantSeparator(token);
|
|
75
|
+
const prefix = separatorIndex === -1
|
|
76
|
+
? ''
|
|
77
|
+
: token.slice(0, separatorIndex + 1);
|
|
78
|
+
let candidate = separatorIndex === -1
|
|
79
|
+
? token
|
|
80
|
+
: token.slice(separatorIndex + 1);
|
|
81
|
+
let leadingImportant = '';
|
|
82
|
+
let trailingImportant = '';
|
|
83
|
+
|
|
84
|
+
if (candidate.startsWith('!')) {
|
|
85
|
+
leadingImportant = '!';
|
|
86
|
+
candidate = candidate.slice(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (candidate.endsWith('!')) {
|
|
90
|
+
trailingImportant = '!';
|
|
91
|
+
candidate = candidate.slice(0, -1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
candidate,
|
|
96
|
+
leadingImportant,
|
|
97
|
+
prefix,
|
|
98
|
+
trailingImportant,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function hasInvalidVariantPrefix(prefix) {
|
|
103
|
+
let bracketDepth = 0;
|
|
104
|
+
|
|
105
|
+
for (const character of prefix) {
|
|
106
|
+
if (character === '[') {
|
|
107
|
+
bracketDepth++;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (character === ']' && bracketDepth > 0) {
|
|
112
|
+
bracketDepth--;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (bracketDepth === 0 && /["'=<]/.test(character)) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function analyzeClassToken(token, options, scalePx) {
|
|
125
|
+
const parsedToken = parseClassToken(token);
|
|
126
|
+
if (parsedToken.prefix && hasInvalidVariantPrefix(parsedToken.prefix)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const match = parsedToken.candidate.match(ARBITRARY_SPACING_CLASS);
|
|
131
|
+
if (!match || !match.groups) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parsedLength = parseLengthToken(match.groups.rawValue);
|
|
136
|
+
if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!options.allowNegative && parsedLength.number < 0) {
|
|
141
|
+
return {
|
|
142
|
+
fixedToken: null,
|
|
143
|
+
nearest: null,
|
|
144
|
+
parsedLength,
|
|
145
|
+
reason: 'negative',
|
|
146
|
+
rawValue: match.groups.rawValue,
|
|
147
|
+
utility: match.groups.utility,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!options.units.includes(parsedLength.unit)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pxValue = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
|
|
156
|
+
if (pxValue === null) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const isOnScale = scalePx.some((entry) => numbersEqual(entry, pxValue));
|
|
161
|
+
if (isOnScale) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const nearest = nearestScaleValues(pxValue, scalePx);
|
|
166
|
+
if (!nearest) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const signedNearest = parsedLength.number < 0
|
|
171
|
+
? -Math.abs(nearest.nearest)
|
|
172
|
+
: nearest.nearest;
|
|
173
|
+
|
|
174
|
+
const replacementNumber = parsedLength.unit === 'px'
|
|
175
|
+
? signedNearest
|
|
176
|
+
: signedNearest / options.baseFontSize;
|
|
177
|
+
|
|
178
|
+
const replacementValue = formatLength(replacementNumber, parsedLength.unit);
|
|
179
|
+
const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
180
|
+
const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
fixedToken,
|
|
184
|
+
nearest,
|
|
185
|
+
parsedLength,
|
|
186
|
+
reason: 'off-scale',
|
|
187
|
+
rawValue: match.groups.rawValue,
|
|
188
|
+
utility: match.groups.utility,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function createTailwindClassAnalyzer(option = {}) {
|
|
193
|
+
const options = normalizeTailwindClassOptions(option);
|
|
194
|
+
const scalePx = normalizeScale(options.scale, options.baseFontSize);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
analyzeClassString(value) {
|
|
198
|
+
const findings = [];
|
|
199
|
+
|
|
200
|
+
for (const segment of findClassSegments(value)) {
|
|
201
|
+
const analysis = analyzeClassToken(segment.token, options, scalePx);
|
|
202
|
+
if (analysis) {
|
|
203
|
+
findings.push({ analysis, segment });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return findings;
|
|
208
|
+
},
|
|
209
|
+
analyzeToken(token) {
|
|
210
|
+
return analyzeClassToken(token, options, scalePx);
|
|
211
|
+
},
|
|
212
|
+
options,
|
|
213
|
+
scalePx,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
module.exports = {
|
|
218
|
+
createTailwindClassAnalyzer,
|
|
219
|
+
findClassSegments,
|
|
220
|
+
normalizeTailwindClassOptions,
|
|
221
|
+
};
|