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 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 Rhythmguard does not cover
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.5.0",
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": "./src/cli/index.js"
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": "git+https://github.com/petrilahdelma/stylelint-plugin-rhythmguard.git"
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 jsonMode = args.includes('--json');
8
- const dir = args.find((a) => !a.startsWith('-'));
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
- if (!dir) {
11
- process.stderr.write('Usage: rhythmguard audit <dir> [--json]\n');
12
- process.exit(1);
116
+ return parsed;
13
117
  }
14
118
 
15
- const resolvedDir = path.resolve(dir);
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
- if (!fs.existsSync(resolvedDir)) {
18
- process.stderr.write(`Directory not found: ${dir}\n`);
19
- process.exit(1);
136
+ return scale;
20
137
  }
21
138
 
22
- const GLOB_PATTERN = `${resolvedDir}/**/*.{css,module.css}`;
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
- const pluginPath = path.resolve(__dirname, '..', 'index.js');
145
+ return value;
146
+ }
25
147
 
26
- async function run() {
27
- const { default: stylelint } = await import('stylelint');
148
+ function assertDirectory(dir) {
149
+ if (!dir) {
150
+ process.stderr.write(HELP);
151
+ process.exit(1);
152
+ }
28
153
 
29
- let result;
30
- try {
31
- result = await stylelint.lint({
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
- const fileResults = result.results || [];
53
- const totalFiles = fileResults.length;
160
+ return resolvedDir;
161
+ }
54
162
 
55
- const offScaleValues = {};
56
- const tokenOpportunities = {};
57
- let filesWithIssues = 0;
58
- let totalWarnings = 0;
163
+ function walkFiles(rootDir) {
164
+ const files = [];
59
165
 
60
- for (const fileResult of fileResults) {
61
- const warnings = fileResult.warnings || [];
62
- if (warnings.length > 0) {
63
- filesWithIssues++;
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
- for (const warning of warnings) {
67
- totalWarnings++;
68
- const text = warning.text || '';
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
- // Extract off-scale values from use-scale messages
71
- // Format: Unexpected off-scale value "13px". Use scale values (nearest: 12px or 16px).
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
- if (tokenMatch) {
86
- const value = tokenMatch[1];
87
- tokenOpportunities[value] = (tokenOpportunities[value] || 0) + 1;
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
- const sortedOffScale = Object.entries(offScaleValues)
93
- .sort((a, b) => b[1] - a[1])
94
- .slice(0, 10);
299
+ return findings;
300
+ }
95
301
 
96
- const sortedTokenOps = Object.entries(tokenOpportunities)
97
- .sort((a, b) => b[1] - a[1])
98
- .slice(0, 10);
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
- if (jsonMode) {
101
- const output = {
102
- directory: dir,
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
- totalWarnings,
106
- offScaleValues: Object.fromEntries(sortedOffScale),
107
- tokenOpportunities: Object.fromEntries(sortedTokenOps),
108
- };
109
- process.stdout.write(JSON.stringify(output, null, 2) + '\n');
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
- // Human-readable output
114
- const pct = totalFiles > 0
115
- ? Math.round((filesWithIssues / totalFiles) * 100)
116
- : 0;
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
- `Rhythmguard Audit: ${dir}`,
496
+ '## Summary',
121
497
  '',
122
- `Files scanned: ${totalFiles}`,
123
- `Files with issues: ${filesWithIssues} (${pct}%)`,
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
- if (sortedOffScale.length > 0) {
128
- const offScaleTotal = Object.values(offScaleValues).reduce(
129
- (a, b) => a + b,
130
- 0,
131
- );
132
- lines.push(`Off-scale values: ${offScaleTotal}`);
133
- for (const [value, count] of sortedOffScale) {
134
- lines.push(` ${value.padEnd(10)} ×${count}`);
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 (sortedTokenOps.length > 0) {
140
- const tokenTotal = Object.values(tokenOpportunities).reduce(
141
- (a, b) => a + b,
142
- 0,
143
- );
144
- lines.push(`Token opportunities: ${tokenTotal}`);
145
- for (const [value, count] of sortedTokenOps) {
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
- if (totalWarnings === 0) {
152
- lines.push('No issues found. Your spacing is on scale.');
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 "npx stylelint --fix" to auto-correct.');
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
- process.stdout.write(lines.join('\n'));
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 scale drift and token opportunities
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
- formatLength,
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
- 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>[^\]]+)\]$/;
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 segments = findClassSegments(value);
128
- if (segments.length === 0) {
26
+ const findings = analyzer.analyzeClassString(value);
27
+ if (findings.length === 0) {
129
28
  return;
130
29
  }
131
- for (const segment of segments) {
132
- const analysis = analyzeToken(segment.token, options, scalePx);
133
- if (!analysis) {
134
- continue;
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
- allowFix && analysis.reason !== 'negative' && node.type === 'Literal'
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 options = getOptions(context);
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, options, scalePx, true);
103
+ maybeCheckNodeText(node, sourceCode, context, analyzer, true);
199
104
  },
200
105
  TemplateElement(node) {
201
- maybeCheckNodeText(node, sourceCode, context, options, scalePx, false);
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
+ };