stylelint-plugin-rhythmguard 1.4.2 → 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.
@@ -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,39 @@
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 design-system drift across CSS and Tailwind class strings
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 audit ./src --format markdown
20
+ npx rhythmguard init
21
+ npx rhythmguard doctor
22
+ `;
23
+
24
+ if (!command || command === '--help' || command === '-h') {
25
+ process.stdout.write(HELP);
26
+ process.exit(0);
27
+ }
28
+
29
+ if (command === 'audit') {
30
+ require('./audit');
31
+ } else if (command === 'init') {
32
+ require('./init');
33
+ } else if (command === 'doctor') {
34
+ require('./doctor');
35
+ } else {
36
+ process.stderr.write(`Unknown command: ${command}\n\n`);
37
+ process.stdout.write(HELP);
38
+ process.exit(1);
39
+ }
@@ -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
  };
@@ -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
  },