stylelint-plugin-rhythmguard 1.4.2 → 1.5.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/README.md CHANGED
@@ -1,10 +1,10 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg" width="100%" alt="Rhythmguard banner in Geist Pixel" />
2
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=3" width="100%" alt="Rhythmguard banner showing spacing scale ruler and lint output" />
3
3
  </p>
4
4
 
5
5
  # stylelint-plugin-rhythmguard
6
6
 
7
- High-precision spacing governance for CSS and design systems.
7
+ Token governance for CSS and Tailwind. Enforce spacing scales, require design tokens, and catch arbitrary values before they ship.
8
8
 
9
9
  [![CI](https://img.shields.io/github/actions/workflow/status/petrilahdelma/stylelint-plugin-rhythmguard/ci.yml?branch=main&label=ci)](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
10
10
  [![npm version](https://img.shields.io/npm/v/stylelint-plugin-rhythmguard.svg)](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
@@ -12,25 +12,48 @@ High-precision spacing governance for CSS and design systems.
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-white.svg)](./LICENSE)
13
13
  [![Node](https://img.shields.io/badge/node-%3E%3D18.18-black.svg)](https://nodejs.org/)
14
14
 
15
- `stylelint-plugin-rhythmguard` enforces scale and token discipline across spacing, radius, typography, size, and translate motion offsets.
15
+ Rhythmguard enforces scale and token discipline across spacing, radius, typography, size, and motion offsets — in CSS declarations and Tailwind class strings.
16
16
 
17
- ## Demo
17
+ Built for teams that want:
18
18
 
19
- <p align="center">
20
- <a href="https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/assets/rhythmguard-campaign-60s.webm">
21
- <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-campaign-60s.gif" width="100%" alt="Rhythmguard 60-second demo" />
22
- </a>
23
- </p>
19
+ - zero random spacing values in production
20
+ - token-first workflows with autofix migration
21
+ - Tailwind arbitrary value governance (`p-[13px]` `p-[12px]`)
22
+ - consistent layout rhythm across components and pages
24
23
 
25
- I built Rhythmguard after 20 years of watching teams ignore spacing scales and ship arbitrary pixel values everywhere.
24
+ ## Quick Start: Next.js + Tailwind
25
+
26
+ ```bash
27
+ npm install --save-dev stylelint stylelint-plugin-rhythmguard
28
+ ```
29
+
30
+ **.stylelintrc.json:**
31
+
32
+ ```json
33
+ {
34
+ "extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
35
+ }
36
+ ```
37
+
38
+ **eslint.config.js** (for Tailwind class-string governance):
39
+
40
+ ```js
41
+ import rhythmguard from 'stylelint-plugin-rhythmguard/eslint';
26
42
 
27
- It is built for teams that want:
43
+ export default [
44
+ {
45
+ plugins: { 'rhythmguard-tailwind': rhythmguard },
46
+ rules: {
47
+ 'rhythmguard-tailwind/tailwind-class-use-scale': [
48
+ 'error',
49
+ { scale: [0, 4, 8, 12, 16, 24, 32] }
50
+ ],
51
+ },
52
+ },
53
+ ];
54
+ ```
28
55
 
29
- - zero random spacing values in production CSS
30
- - consistent numeric scales for radius, typography, and sizing primitives
31
- - token-first spacing workflows
32
- - predictable autofix behavior for large migrations
33
- - consistent layout rhythm across web surfaces
56
+ This gives you spacing governance in both CSS files and JSX/TSX templates.
34
57
 
35
58
  ## Rule Matrix
36
59
 
@@ -44,6 +67,16 @@ It is built for teams that want:
44
67
  | `rhythmguard/prefer-token` | Enforces token usage over raw spacing literals | Yes, with `tokenMap` |
45
68
  | `rhythmguard/no-offscale-transform` | Enforces scale-aligned `translate*` motion offsets | Yes, nearest safe value |
46
69
 
70
+ ## Demo
71
+
72
+ <p align="center">
73
+ <a href="https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/assets/rhythmguard-campaign-60s.webm">
74
+ <img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-campaign-60s.gif" width="100%" alt="Rhythmguard 60-second demo" />
75
+ </a>
76
+ </p>
77
+
78
+ I built Rhythmguard after 20 years of watching teams ignore spacing scales and ship arbitrary pixel values everywhere.
79
+
47
80
  ## Installation
48
81
 
49
82
  ```bash
@@ -66,32 +99,32 @@ npm install --save-dev stylelint-plugin-rhythmguard
66
99
 
67
100
  ## Quick Start
68
101
 
69
- ### Recommended config
102
+ ### Tailwind config
70
103
 
71
104
  ```json
72
105
  {
73
- "extends": ["stylelint-plugin-rhythmguard/configs/recommended"]
106
+ "extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
74
107
  }
75
108
  ```
76
109
 
77
- ### Strict config
110
+ ### Recommended config
78
111
 
79
112
  ```json
80
113
  {
81
- "extends": ["stylelint-plugin-rhythmguard/configs/strict"]
114
+ "extends": ["stylelint-plugin-rhythmguard/configs/recommended"]
82
115
  }
83
116
  ```
84
117
 
85
- `strict` intentionally delegates transform translation enforcement to `rhythmguard/no-offscale-transform` to reduce overlapping warnings from `use-scale`.
86
-
87
- ### Tailwind config
118
+ ### Strict config
88
119
 
89
120
  ```json
90
121
  {
91
- "extends": ["stylelint-plugin-rhythmguard/configs/tailwind"]
122
+ "extends": ["stylelint-plugin-rhythmguard/configs/strict"]
92
123
  }
93
124
  ```
94
125
 
126
+ `strict` intentionally delegates transform translation enforcement to `rhythmguard/no-offscale-transform` to reduce overlapping warnings from `use-scale`.
127
+
95
128
  ### Expanded config
96
129
 
97
130
  ```json
@@ -122,15 +155,28 @@ npm install --save-dev stylelint-plugin-rhythmguard
122
155
 
123
156
  `migration` keeps on-scale numeric values temporarily while auto-building token mappings from CSS custom properties and optional Tailwind spacing config.
124
157
 
158
+ ### React / Next.js + Tailwind config
159
+
160
+ ```json
161
+ {
162
+ "extends": ["stylelint-plugin-rhythmguard/configs/react-tailwind"]
163
+ }
164
+ ```
165
+
166
+ `react-tailwind` extends the tailwind config with CSS Modules overrides (spacing + radius enforcement) and ignores Next.js build directories.
167
+
125
168
  Stable shared config entry points:
126
169
 
127
170
  - `stylelint-plugin-rhythmguard/configs/recommended`
128
171
  - `stylelint-plugin-rhythmguard/configs/strict`
129
172
  - `stylelint-plugin-rhythmguard/configs/tailwind`
173
+ - `stylelint-plugin-rhythmguard/configs/react-tailwind`
130
174
  - `stylelint-plugin-rhythmguard/configs/expanded`
131
175
  - `stylelint-plugin-rhythmguard/configs/logical`
132
176
  - `stylelint-plugin-rhythmguard/configs/migration`
133
177
 
178
+ Framework-specific setup for Vue, Lit, Astro, and SvelteKit: [`docs/FRAMEWORKS.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FRAMEWORKS.md)
179
+
134
180
  ## Comparison and Migration Recipes
135
181
 
136
182
  - Side-by-side tool fit guide with migration snippets: [`docs/COMPARISON.md`](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/blob/main/docs/COMPARISON.md)
@@ -409,7 +455,7 @@ Options:
409
455
  | `mathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Restricts linting to specific 1-based argument indexes per math function |
410
456
  | `ignoreMathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Excludes specific 1-based argument indexes per math function |
411
457
  | `tokenMap` | `Record<string,string>` | `{}` | Enables autofix from raw value to token |
412
- | `tokenMapFile` | `string` | `null` | JSON file path to merge additional token mappings |
458
+ | `tokenMapFile` | `string` | `null` | JSON file path to merge additional token mappings (supports flat, Style Dictionary, and W3C DTCG formats) |
413
459
  | `tokenMapFromCssCustomProperties` | `boolean` | `false` | Auto-builds mappings from matching custom property declarations in the same stylesheet |
414
460
  | `tokenMapFromTailwindSpacing` | `boolean` | `false` | Auto-builds mappings from `theme.spacing` and `theme.extend.spacing` in Tailwind config |
415
461
  | `tailwindConfigPath` | `string` | `null` | Path to Tailwind config used by `tokenMapFromTailwindSpacing` (`.js`, `.cjs`, `.mjs`) |
@@ -450,6 +496,12 @@ Rhythmguard works well in Tailwind projects, but it enforces what Stylelint can
450
496
  - CSS Modules (for example `*.module.css`)
451
497
  - declarations inside `@layer` blocks
452
498
 
499
+ ### Tailwind v4 @theme tokens
500
+
501
+ The `tailwind` config preset automatically extracts spacing tokens from Tailwind v4 `@theme` blocks and uses them for `prefer-token` enforcement. Raw values like `padding: 16px` are autofixed to `padding: var(--spacing-4)`.
502
+
503
+ See [`docs/TAILWIND.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/TAILWIND.md) for full setup.
504
+
453
505
  ### What Rhythmguard does not cover
454
506
 
455
507
  - Tailwind class strings in templates/JSX/TSX, for example:
@@ -480,6 +532,18 @@ export default [
480
532
 
481
533
  This rule targets arbitrary spacing utilities such as `p-[13px]`, `gap-[18px]`, `translate-x-[10px]`, and autofixes to the nearest configured scale value.
482
534
 
535
+ #### Supported patterns
536
+
537
+ The rule checks every string literal in your code, so it works automatically with common utility functions:
538
+
539
+ - `cn("p-[13px]")` / `cn("p-[13px]", condition && "m-[7px]")`
540
+ - `clsx("p-[13px]", "gap-[18px]")`
541
+ - `twMerge("p-[13px]", otherClasses)`
542
+ - `cva("base", { variants: { size: { sm: "p-[5px]" } } })`
543
+ - `<div className={cn("p-[13px]")} />`
544
+
545
+ No extra config needed — if the string contains an arbitrary spacing value, it gets caught and autofixed.
546
+
483
547
  ### Recommended stack for full Tailwind enforcement
484
548
 
485
549
  Use both layers:
@@ -521,6 +585,35 @@ console.log(rhythmguard.presets.scales['rhythmic-4']);
521
585
  console.log(Object.keys(rhythmguard.eslint.rules));
522
586
  ```
523
587
 
588
+ ## Token File Formats
589
+
590
+ The `tokenMapFile` option supports multiple JSON formats:
591
+
592
+ **Flat token-to-value:**
593
+
594
+ ```json
595
+ { "--spacing-4": "16px", "--spacing-3": "12px" }
596
+ ```
597
+
598
+ **Style Dictionary:**
599
+
600
+ ```json
601
+ { "--spacing-4": { "value": "16px" } }
602
+ ```
603
+
604
+ **W3C DTCG (Design Token Community Group):**
605
+
606
+ ```json
607
+ {
608
+ "spacing": {
609
+ "4": { "$value": "16px", "$type": "dimension" },
610
+ "2": { "$value": "8px", "$type": "dimension" }
611
+ }
612
+ }
613
+ ```
614
+
615
+ Nested DTCG groups are walked recursively. The key path becomes the CSS variable name: `spacing.4` → `var(--spacing-4)`. Non-length values (colors, fonts) are ignored automatically.
616
+
524
617
  ## Autofix Philosophy
525
618
 
526
619
  Rhythmguard only applies deterministic fixes:
package/package.json CHANGED
@@ -1,7 +1,10 @@
1
1
  {
2
2
  "name": "stylelint-plugin-rhythmguard",
3
- "version": "1.4.2",
4
- "description": "Stylelint plugin for spacing scale, token enforcement, and Tailwind class-string governance",
3
+ "version": "1.5.0",
4
+ "description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
5
+ "bin": {
6
+ "rhythmguard": "./src/cli/index.js"
7
+ },
5
8
  "keywords": [
6
9
  "stylelint",
7
10
  "stylelint-plugin",
@@ -46,6 +49,10 @@
46
49
  "require": "./src/configs/migration.js",
47
50
  "import": "./src/configs/migration.mjs"
48
51
  },
52
+ "./configs/react-tailwind": {
53
+ "require": "./src/configs/react-tailwind.js",
54
+ "import": "./src/configs/react-tailwind.mjs"
55
+ },
49
56
  "./presets": {
50
57
  "require": "./src/presets/index.js",
51
58
  "import": "./src/presets/index.mjs"
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const args = process.argv.slice(3);
7
+ const jsonMode = args.includes('--json');
8
+ const dir = args.find((a) => !a.startsWith('-'));
9
+
10
+ if (!dir) {
11
+ process.stderr.write('Usage: rhythmguard audit <dir> [--json]\n');
12
+ process.exit(1);
13
+ }
14
+
15
+ const resolvedDir = path.resolve(dir);
16
+
17
+ if (!fs.existsSync(resolvedDir)) {
18
+ process.stderr.write(`Directory not found: ${dir}\n`);
19
+ process.exit(1);
20
+ }
21
+
22
+ const GLOB_PATTERN = `${resolvedDir}/**/*.{css,module.css}`;
23
+
24
+ const pluginPath = path.resolve(__dirname, '..', 'index.js');
25
+
26
+ async function run() {
27
+ const { default: stylelint } = await import('stylelint');
28
+
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`);
49
+ process.exit(1);
50
+ }
51
+
52
+ const fileResults = result.results || [];
53
+ const totalFiles = fileResults.length;
54
+
55
+ const offScaleValues = {};
56
+ const tokenOpportunities = {};
57
+ let filesWithIssues = 0;
58
+ let totalWarnings = 0;
59
+
60
+ for (const fileResult of fileResults) {
61
+ const warnings = fileResult.warnings || [];
62
+ if (warnings.length > 0) {
63
+ filesWithIssues++;
64
+ }
65
+
66
+ for (const warning of warnings) {
67
+ totalWarnings++;
68
+ const text = warning.text || '';
69
+
70
+ // Extract off-scale values from use-scale messages
71
+ // Format: Unexpected off-scale value "13px". Use scale values (nearest: 12px or 16px).
72
+ const offScaleMatch = text.match(
73
+ /Unexpected off-scale value "([^"]+)"/,
74
+ );
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
+ const tokenMatch = text.match(
83
+ /Unexpected raw scale value "([^"]+)"/,
84
+ );
85
+ if (tokenMatch) {
86
+ const value = tokenMatch[1];
87
+ tokenOpportunities[value] = (tokenOpportunities[value] || 0) + 1;
88
+ }
89
+ }
90
+ }
91
+
92
+ const sortedOffScale = Object.entries(offScaleValues)
93
+ .sort((a, b) => b[1] - a[1])
94
+ .slice(0, 10);
95
+
96
+ const sortedTokenOps = Object.entries(tokenOpportunities)
97
+ .sort((a, b) => b[1] - a[1])
98
+ .slice(0, 10);
99
+
100
+ if (jsonMode) {
101
+ const output = {
102
+ directory: dir,
103
+ totalFiles,
104
+ filesWithIssues,
105
+ totalWarnings,
106
+ offScaleValues: Object.fromEntries(sortedOffScale),
107
+ tokenOpportunities: Object.fromEntries(sortedTokenOps),
108
+ };
109
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
110
+ return;
111
+ }
112
+
113
+ // Human-readable output
114
+ const pct = totalFiles > 0
115
+ ? Math.round((filesWithIssues / totalFiles) * 100)
116
+ : 0;
117
+
118
+ const lines = [
119
+ '',
120
+ `Rhythmguard Audit: ${dir}`,
121
+ '',
122
+ `Files scanned: ${totalFiles}`,
123
+ `Files with issues: ${filesWithIssues} (${pct}%)`,
124
+ '',
125
+ ];
126
+
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}`);
135
+ }
136
+ lines.push('');
137
+ }
138
+
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}`);
147
+ }
148
+ lines.push('');
149
+ }
150
+
151
+ if (totalWarnings === 0) {
152
+ lines.push('No issues found. Your spacing is on scale.');
153
+ } else {
154
+ lines.push('Run "npx stylelint --fix" to auto-correct.');
155
+ }
156
+
157
+ lines.push('');
158
+ process.stdout.write(lines.join('\n'));
159
+ }
160
+
161
+ run();
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const cwd = process.cwd();
7
+ let issues = 0;
8
+
9
+ function pass(msg) {
10
+ process.stdout.write(`✓ ${msg}\n`);
11
+ }
12
+
13
+ function fail(msg, fix) {
14
+ issues++;
15
+ process.stdout.write(`✗ ${msg}\n`);
16
+ if (fix) {
17
+ process.stdout.write(` → ${fix}\n`);
18
+ }
19
+ }
20
+
21
+ function skip(msg) {
22
+ process.stdout.write(`- ${msg}\n`);
23
+ }
24
+
25
+ // Check 1: Stylelint installed
26
+ function checkStylelint() {
27
+ try {
28
+ const stylelintPkg = require.resolve('stylelint/package.json', {
29
+ paths: [cwd],
30
+ });
31
+ const version = require(stylelintPkg).version;
32
+ pass(`stylelint installed (v${version})`);
33
+ return true;
34
+ } catch {
35
+ fail(
36
+ 'stylelint not installed',
37
+ 'Run: npm install --save-dev stylelint',
38
+ );
39
+ return false;
40
+ }
41
+ }
42
+
43
+ // Check 2: Config found
44
+ function findConfig() {
45
+ const configFiles = [
46
+ '.stylelintrc',
47
+ '.stylelintrc.json',
48
+ '.stylelintrc.js',
49
+ '.stylelintrc.cjs',
50
+ '.stylelintrc.mjs',
51
+ '.stylelintrc.yml',
52
+ '.stylelintrc.yaml',
53
+ 'stylelint.config.js',
54
+ 'stylelint.config.cjs',
55
+ 'stylelint.config.mjs',
56
+ ];
57
+
58
+ for (const file of configFiles) {
59
+ const fullPath = path.join(cwd, file);
60
+ if (fs.existsSync(fullPath)) {
61
+ pass(`config found (${file})`);
62
+ return fullPath;
63
+ }
64
+ }
65
+
66
+ // Check package.json
67
+ const pkgPath = path.join(cwd, 'package.json');
68
+ if (fs.existsSync(pkgPath)) {
69
+ try {
70
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
71
+ if (pkg.stylelint) {
72
+ pass('config found (package.json stylelint key)');
73
+ return pkgPath;
74
+ }
75
+ } catch {
76
+ // ignore
77
+ }
78
+ }
79
+
80
+ fail(
81
+ 'no Stylelint config found',
82
+ 'Run: npx rhythmguard init',
83
+ );
84
+ return null;
85
+ }
86
+
87
+ // Check 3: Config references Rhythmguard
88
+ function checkConfigReferences(configPath) {
89
+ if (!configPath) {
90
+ skip('config reference check skipped (no config)');
91
+ return null;
92
+ }
93
+
94
+ try {
95
+ const content = fs.readFileSync(configPath, 'utf8');
96
+
97
+ if (content.includes('rhythmguard')) {
98
+ pass('config references rhythmguard');
99
+ return content;
100
+ }
101
+
102
+ // Check package.json stylelint key
103
+ if (configPath.endsWith('package.json')) {
104
+ const pkg = JSON.parse(content);
105
+ const stylelintConfig = JSON.stringify(pkg.stylelint || {});
106
+ if (stylelintConfig.includes('rhythmguard')) {
107
+ pass('config references rhythmguard');
108
+ return stylelintConfig;
109
+ }
110
+ }
111
+
112
+ fail(
113
+ 'config does not reference rhythmguard',
114
+ 'Add: "extends": ["stylelint-plugin-rhythmguard/configs/recommended"]',
115
+ );
116
+ return content;
117
+ } catch {
118
+ fail(
119
+ 'could not read config file',
120
+ `Check permissions on ${configPath}`,
121
+ );
122
+ return null;
123
+ }
124
+ }
125
+
126
+ // Check 4: Token pattern valid
127
+ function checkTokenPattern(configContent) {
128
+ if (!configContent) {
129
+ skip('token pattern check skipped (no config content)');
130
+ return;
131
+ }
132
+
133
+ const patternMatch = configContent.match(
134
+ /tokenPattern['":\s]+['"]([^'"]+)['"]/,
135
+ );
136
+
137
+ if (!patternMatch) {
138
+ skip('token pattern check skipped (not configured)');
139
+ return;
140
+ }
141
+
142
+ try {
143
+ new RegExp(patternMatch[1]);
144
+ pass(`token pattern valid (${patternMatch[1]})`);
145
+ } catch {
146
+ fail(
147
+ `token pattern invalid: ${patternMatch[1]}`,
148
+ 'Fix the regex in tokenPattern option',
149
+ );
150
+ }
151
+ }
152
+
153
+ // Check 5: Tailwind config exists
154
+ function checkTailwindConfig(configContent) {
155
+ if (!configContent) {
156
+ skip('tailwind config check skipped (no config content)');
157
+ return;
158
+ }
159
+
160
+ const tailwindPathMatch = configContent.match(
161
+ /tailwindConfigPath['":\s]+['"]([^'"]+)['"]/,
162
+ );
163
+
164
+ if (!tailwindPathMatch) {
165
+ skip('tailwind config check skipped (not configured)');
166
+ return;
167
+ }
168
+
169
+ const tailwindPath = path.resolve(cwd, tailwindPathMatch[1]);
170
+ if (fs.existsSync(tailwindPath)) {
171
+ pass(`tailwind config found (${tailwindPathMatch[1]})`);
172
+ } else {
173
+ fail(
174
+ `tailwind config not found at ${tailwindPathMatch[1]}`,
175
+ 'Update tailwindConfigPath or remove tokenMapFromTailwindSpacing',
176
+ );
177
+ }
178
+ }
179
+
180
+ // Check 6: Custom syntax installed
181
+ function checkCustomSyntax(configContent) {
182
+ if (!configContent) {
183
+ skip('custom syntax check skipped (no config content)');
184
+ return;
185
+ }
186
+
187
+ const syntaxMatch = configContent.match(
188
+ /customSyntax['":\s]+['"]([^'"]+)['"]/,
189
+ );
190
+
191
+ if (!syntaxMatch) {
192
+ skip('custom syntax check skipped (not configured)');
193
+ return;
194
+ }
195
+
196
+ const pkg = syntaxMatch[1];
197
+ try {
198
+ require.resolve(pkg, { paths: [cwd] });
199
+ pass(`custom syntax installed (${pkg})`);
200
+ } catch {
201
+ fail(
202
+ `custom syntax package not installed: ${pkg}`,
203
+ `Run: npm install --save-dev ${pkg}`,
204
+ );
205
+ }
206
+ }
207
+
208
+ function run() {
209
+ process.stdout.write('\nRhythmguard Doctor\n\n');
210
+
211
+ const stylelintOk = checkStylelint();
212
+ const configPath = findConfig();
213
+ const configContent = checkConfigReferences(configPath);
214
+ checkTokenPattern(configContent);
215
+ checkTailwindConfig(configContent);
216
+ checkCustomSyntax(configContent);
217
+
218
+ process.stdout.write('\n');
219
+
220
+ if (issues === 0) {
221
+ process.stdout.write('All checks passed.\n\n');
222
+ } else {
223
+ process.stdout.write(
224
+ `${issues} issue${issues === 1 ? '' : 's'} found.\n\n`,
225
+ );
226
+ }
227
+
228
+ process.exit(stylelintOk && issues === 0 ? 0 : 1);
229
+ }
230
+
231
+ run();
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const command = process.argv[2];
5
+
6
+ const HELP = `Usage: rhythmguard <command>
7
+
8
+ Commands:
9
+ audit <dir> Report scale drift and token opportunities
10
+ init Scaffold a Rhythmguard config for your project
11
+ doctor Validate your Rhythmguard setup
12
+
13
+ Options:
14
+ --help Show this help message
15
+
16
+ Examples:
17
+ npx rhythmguard audit ./src
18
+ npx rhythmguard audit ./src --json
19
+ npx rhythmguard init
20
+ npx rhythmguard doctor
21
+ `;
22
+
23
+ if (!command || command === '--help' || command === '-h') {
24
+ process.stdout.write(HELP);
25
+ process.exit(0);
26
+ }
27
+
28
+ if (command === 'audit') {
29
+ require('./audit');
30
+ } else if (command === 'init') {
31
+ require('./init');
32
+ } else if (command === 'doctor') {
33
+ require('./doctor');
34
+ } else {
35
+ process.stderr.write(`Unknown command: ${command}\n\n`);
36
+ process.stdout.write(HELP);
37
+ process.exit(1);
38
+ }
@@ -0,0 +1,128 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const readline = require('node:readline');
6
+
7
+ function ask(question) {
8
+ const rl = readline.createInterface({
9
+ input: process.stdin,
10
+ output: process.stdout,
11
+ });
12
+ return new Promise((resolve) => {
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolve(answer.trim().toLowerCase());
16
+ });
17
+ });
18
+ }
19
+
20
+ function detect() {
21
+ const cwd = process.cwd();
22
+ const pkgPath = path.join(cwd, 'package.json');
23
+
24
+ let pkg = {};
25
+ if (fs.existsSync(pkgPath)) {
26
+ try {
27
+ pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
28
+ } catch {
29
+ // ignore
30
+ }
31
+ }
32
+
33
+ const allDeps = {
34
+ ...pkg.dependencies,
35
+ ...pkg.devDependencies,
36
+ };
37
+
38
+ const hasTailwindConfig =
39
+ fs.existsSync(path.join(cwd, 'tailwind.config.js')) ||
40
+ fs.existsSync(path.join(cwd, 'tailwind.config.cjs')) ||
41
+ fs.existsSync(path.join(cwd, 'tailwind.config.mjs')) ||
42
+ fs.existsSync(path.join(cwd, 'tailwind.config.ts'));
43
+
44
+ const hasTailwindDep = Boolean(
45
+ allDeps.tailwindcss || allDeps['@tailwindcss/postcss'],
46
+ );
47
+
48
+ const hasNextConfig =
49
+ fs.existsSync(path.join(cwd, 'next.config.js')) ||
50
+ fs.existsSync(path.join(cwd, 'next.config.mjs')) ||
51
+ fs.existsSync(path.join(cwd, 'next.config.ts'));
52
+
53
+ const hasExistingConfig =
54
+ fs.existsSync(path.join(cwd, '.stylelintrc')) ||
55
+ fs.existsSync(path.join(cwd, '.stylelintrc.json')) ||
56
+ fs.existsSync(path.join(cwd, '.stylelintrc.js')) ||
57
+ fs.existsSync(path.join(cwd, '.stylelintrc.cjs')) ||
58
+ fs.existsSync(path.join(cwd, '.stylelintrc.mjs')) ||
59
+ fs.existsSync(path.join(cwd, '.stylelintrc.yml')) ||
60
+ fs.existsSync(path.join(cwd, '.stylelintrc.yaml')) ||
61
+ fs.existsSync(path.join(cwd, 'stylelint.config.js')) ||
62
+ fs.existsSync(path.join(cwd, 'stylelint.config.cjs')) ||
63
+ fs.existsSync(path.join(cwd, 'stylelint.config.mjs')) ||
64
+ Boolean(pkg.stylelint);
65
+
66
+ const tailwind = hasTailwindConfig || hasTailwindDep;
67
+ const nextjs = hasNextConfig;
68
+
69
+ return { tailwind, nextjs, hasExistingConfig };
70
+ }
71
+
72
+ function selectProfile(stack) {
73
+ if (stack.nextjs && stack.tailwind) {
74
+ return 'react-tailwind';
75
+ }
76
+ if (stack.tailwind) {
77
+ return 'tailwind';
78
+ }
79
+ return 'recommended';
80
+ }
81
+
82
+ async function run() {
83
+ process.stdout.write('\nRhythmguard Init\n\n');
84
+
85
+ const stack = detect();
86
+
87
+ // Report detection
88
+ const detected = [];
89
+ if (stack.tailwind) detected.push('Tailwind CSS');
90
+ if (stack.nextjs) detected.push('Next.js');
91
+ if (detected.length > 0) {
92
+ process.stdout.write(`Detected: ${detected.join(', ')}\n`);
93
+ } else {
94
+ process.stdout.write('Detected: plain CSS project\n');
95
+ }
96
+
97
+ // Warn about existing config
98
+ if (stack.hasExistingConfig) {
99
+ process.stdout.write('\n⚠ Existing Stylelint config found.\n');
100
+ const answer = await ask('Overwrite? (y/n) ');
101
+ if (answer !== 'y' && answer !== 'yes') {
102
+ process.stdout.write('Aborted.\n');
103
+ process.exit(0);
104
+ }
105
+ }
106
+
107
+ const profile = selectProfile(stack);
108
+ process.stdout.write(`\nProfile: ${profile}\n`);
109
+
110
+ const answer = await ask('Write .stylelintrc.json? (y/n) ');
111
+ if (answer !== 'y' && answer !== 'yes') {
112
+ process.stdout.write('Aborted.\n');
113
+ process.exit(0);
114
+ }
115
+
116
+ const config = {
117
+ extends: [`stylelint-plugin-rhythmguard/configs/${profile}`],
118
+ };
119
+
120
+ const configPath = path.join(process.cwd(), '.stylelintrc.json');
121
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
122
+
123
+ process.stdout.write(`\n✓ Wrote ${configPath}\n`);
124
+ process.stdout.write(`\nNext steps:\n`);
125
+ process.stdout.write(` npx stylelint "src/**/*.css"\n\n`);
126
+ }
127
+
128
+ run();
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ extends: [
5
+ 'stylelint-plugin-rhythmguard/configs/tailwind',
6
+ ],
7
+ ignoreFiles: [
8
+ '.next/**',
9
+ 'out/**',
10
+ 'node_modules/**',
11
+ ],
12
+ overrides: [
13
+ {
14
+ files: ['**/*.module.css'],
15
+ rules: {
16
+ 'rhythmguard/use-scale': [
17
+ true,
18
+ {
19
+ propertyGroups: ['spacing', 'radius'],
20
+ },
21
+ ],
22
+ },
23
+ },
24
+ ],
25
+ };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from 'node:module';
2
+ const require = createRequire(import.meta.url);
3
+ const config = require('./react-tailwind.js');
4
+ export default config;
@@ -5,4 +5,13 @@ module.exports = {
5
5
  'stylelint-config-tailwindcss',
6
6
  'stylelint-plugin-rhythmguard/configs/strict',
7
7
  ],
8
+ rules: {
9
+ 'rhythmguard/prefer-token': [
10
+ true,
11
+ {
12
+ tokenPattern: '^--spacing-',
13
+ tokenMapFromCssCustomProperties: true,
14
+ },
15
+ ],
16
+ },
8
17
  };
@@ -79,6 +79,31 @@ function mergeExplicitTokenMap(target, source) {
79
79
  return target;
80
80
  }
81
81
 
82
+ function walkTokenGroup(map, group, prefix, baseFontSize) {
83
+ for (const [key, value] of Object.entries(group)) {
84
+ const tokenName = `${prefix}-${key}`;
85
+
86
+ if (!isPlainObject(value)) {
87
+ continue;
88
+ }
89
+
90
+ // Leaf node with $value (DTCG)
91
+ if (typeof value.$value === 'string') {
92
+ addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
93
+ continue;
94
+ }
95
+
96
+ // Leaf node with value (Style Dictionary)
97
+ if (typeof value.value === 'string') {
98
+ addLengthValueMapping(map, value.value, tokenName, baseFontSize);
99
+ continue;
100
+ }
101
+
102
+ // Nested group — recurse deeper
103
+ walkTokenGroup(map, value, tokenName, baseFontSize);
104
+ }
105
+ }
106
+
82
107
  function mergeTokenMapFromFile({
83
108
  baseFontSize,
84
109
  currentMap,
@@ -130,8 +155,21 @@ function mergeTokenMapFromFile({
130
155
  continue;
131
156
  }
132
157
 
133
- if (isPlainObject(entryValue) && typeof entryValue.value === 'string') {
134
- addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
158
+ if (isPlainObject(entryValue)) {
159
+ // Style Dictionary format: { value: "16px" }
160
+ if (typeof entryValue.value === 'string') {
161
+ addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
162
+ continue;
163
+ }
164
+
165
+ // W3C DTCG format: { $value: "16px", $type: "dimension" }
166
+ if (typeof entryValue.$value === 'string') {
167
+ addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
168
+ continue;
169
+ }
170
+
171
+ // Nested group — recurse (e.g. { spacing: { 4: { $value: "16px" } } })
172
+ walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
135
173
  }
136
174
  }
137
175
 
@@ -324,7 +362,7 @@ function buildEffectiveTokenMap({
324
362
  root,
325
363
  tokenRegex,
326
364
  }) {
327
- let tokenMap = mergeExplicitTokenMap({}, options.tokenMap);
365
+ let tokenMap = {};
328
366
 
329
367
  if (options.tokenMapFile) {
330
368
  tokenMap = mergeTokenMapFromFile({
@@ -350,6 +388,9 @@ function buildEffectiveTokenMap({
350
388
  });
351
389
  }
352
390
 
391
+ // Explicit tokenMap applied last so it takes precedence over auto-derived tokens
392
+ tokenMap = mergeExplicitTokenMap(tokenMap, options.tokenMap);
393
+
353
394
  return tokenMap;
354
395
  }
355
396