stylelint-plugin-rhythmguard 3.12.0 → 3.13.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -1
  4. package/src/audit/args.js +23 -4
  5. package/src/audit/baseline.js +15 -2
  6. package/src/audit/codemod.js +56 -5
  7. package/src/audit/config.js +2 -1
  8. package/src/audit/contract.js +3 -0
  9. package/src/audit/decisions.js +45 -16
  10. package/src/audit/render-markdown.js +22 -21
  11. package/src/audit/render-text.js +1 -0
  12. package/src/audit/render-utils.js +28 -1
  13. package/src/audit/report.js +14 -2
  14. package/src/audit/scan/files.js +139 -30
  15. package/src/audit/token-chains.js +4 -1
  16. package/src/audit/token-ladders.js +23 -2
  17. package/src/cli/doctor.js +28 -3
  18. package/src/cli/fix.js +7 -6
  19. package/src/cli/index.js +2 -2
  20. package/src/cli/init.js +41 -5
  21. package/src/cli/quickstart.js +46 -8
  22. package/src/configs/embed.js +2 -0
  23. package/src/configs/motion.js +1 -0
  24. package/src/configs/recommended.js +1 -0
  25. package/src/configs/strict.js +1 -0
  26. package/src/configs/tailwind.js +1 -0
  27. package/src/core/decisions.js +2 -1
  28. package/src/core/json.js +25 -0
  29. package/src/core/length.js +11 -0
  30. package/src/core/options.js +39 -20
  31. package/src/core/scale-inference.js +78 -23
  32. package/src/core/tailwind-class-analysis.js +3 -2
  33. package/src/core/token-map.js +2 -1
  34. package/src/core/token-sources.js +2 -1
  35. package/src/eslint/index.js +14 -8
  36. package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
  37. package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
  38. package/src/rules/no-offscale-transform/index.js +58 -53
  39. package/src/rules/prefer-token/index.js +31 -46
  40. package/src/rules/use-motion-scale/index.js +14 -1
  41. package/src/rules/use-scale/index.js +75 -102
  42. package/src/rules/validate.js +13 -4
  43. package/types/__checks__/consumer.ts +20 -1
  44. package/types/__checks__/eslint-config.ts +8 -0
  45. package/types/__checks__/shipped-configs.ts +96 -0
  46. package/types/audit.d.ts +101 -16
  47. package/types/index.d.ts +15 -0
  48. package/types/shared.d.ts +126 -20
@@ -16,6 +16,16 @@ const { createAuditReport } = require('../audit/report');
16
16
  const { detect } = require('./init');
17
17
  const { discoverTokenPackages } = require('../core/scale-inference');
18
18
 
19
+ const HELP = `Usage: rhythmguard [quickstart]
20
+
21
+ Zero-config first run. Detects the stack and token files, infers the spacing
22
+ scale, audits the current directory, prints a config to paste and the audit
23
+ commands that reproduce its result. Never fails on findings; this is a report.
24
+
25
+ Options:
26
+ --help Show this help message
27
+ `;
28
+
19
29
  const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
20
30
  const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
21
31
  const MAX_LISTED = 5;
@@ -116,6 +126,26 @@ function eslintSnippet(scaleValues) {
116
126
  ].join('\n');
117
127
  }
118
128
 
129
+ function shellQuote(value) {
130
+ return /^[\w./@%+=:,-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
131
+ }
132
+
133
+ /**
134
+ * The commands to run next. The audit commands pass what quickstart passed,
135
+ * `--scale auto` and the token files it read, so they report the same scale
136
+ * and the same findings; `audit .` alone would use the default scale (#287).
137
+ */
138
+ function nextCommands(tokenSources) {
139
+ const audit = ['npx rhythmguard audit .', '--scale auto', ...tokenSources.map((file) => `--token-source ${shellQuote(toPosix(file))}`)].join(' ');
140
+ // Each note on its own line above its command, so a long command copies whole.
141
+ return [
142
+ ['Lint with the config above:', 'npx stylelint "**/*.css"'],
143
+ ['PR-ready report:', `${audit} --format markdown`],
144
+ ['Then gate only new drift in CI, with the same flags:', `${audit} --write-baseline`],
145
+ ['Check the setup:', 'npx rhythmguard doctor'],
146
+ ].flatMap(([note, command]) => [note, ` ${command}`]);
147
+ }
148
+
119
149
  function describeScale(scale) {
120
150
  if (!scale) {
121
151
  return 'default';
@@ -125,6 +155,17 @@ function describeScale(scale) {
125
155
  }
126
156
 
127
157
  async function run() {
158
+ // `rhythmguard` alone has no arguments; `rhythmguard quickstart` may have --help (#286).
159
+ for (const arg of process.argv[2] === 'quickstart' ? process.argv.slice(3) : []) {
160
+ if (arg === '--help' || arg === '-h') {
161
+ process.stdout.write(HELP);
162
+ return;
163
+ }
164
+ process.stderr.write(`Unknown option: ${arg}\n\n${HELP}`);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+
128
169
  const cwd = process.cwd();
129
170
  const pkg = readPackage(cwd);
130
171
  const stack = detect();
@@ -144,14 +185,14 @@ async function run() {
144
185
  out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
145
186
  out.push('');
146
187
 
188
+ // With an rc file its token sources are used; otherwise the token files found here.
189
+ const auditTokenSources = rcPresent ? [] : tokenFiles;
147
190
  let report;
148
191
  try {
149
192
  report = await createAuditReport({
150
193
  dir: '.',
151
194
  scale: 'auto',
152
- ...(tokenFiles.length > 0 && !rcPresent
153
- ? { tokenSources: tokenFiles }
154
- : {}),
195
+ ...(auditTokenSources.length > 0 ? { tokenSources: auditTokenSources } : {}),
155
196
  });
156
197
  } catch (error) {
157
198
  process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
@@ -228,13 +269,10 @@ async function run() {
228
269
  }
229
270
 
230
271
  out.push(' Next');
231
- out.push(' npx stylelint "**/*.css" lint with the config above');
232
- out.push(' npx rhythmguard audit . --format markdown PR-ready report');
233
- out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
234
- out.push(' npx rhythmguard doctor check the setup');
272
+ out.push(...nextCommands(auditTokenSources).map((line) => ` ${line}`));
235
273
  out.push('');
236
274
 
237
275
  process.stdout.write(`${out.join('\n')}\n`);
238
276
  }
239
277
 
240
- module.exports = { eslintSnippet, findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
278
+ module.exports = { HELP, eslintSnippet, findTokenFiles, nextCommands, run, suggestedStylelintConfig, tailwindMajor };
@@ -9,6 +9,8 @@
9
9
  * No `extends`, no dependency beyond this plugin, and the shape is frozen for
10
10
  * the life of a major version so a config that embeds it never changes
11
11
  * behaviour on a minor bump.
12
+ *
13
+ * @type {import('../../types/shared').RhythmguardStylelintConfig}
12
14
  */
13
15
  module.exports = {
14
16
  plugins: ['stylelint-plugin-rhythmguard'],
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ /** @type {import('../../types/shared').RhythmguardStylelintConfig} */
3
4
  module.exports = {
4
5
  plugins: ['stylelint-plugin-rhythmguard'],
5
6
  rules: {
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ /** @type {import('../../types/shared').RhythmguardStylelintConfig} */
3
4
  module.exports = {
4
5
  plugins: ['stylelint-plugin-rhythmguard'],
5
6
  rules: {
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ /** @type {import('../../types/shared').RhythmguardStylelintConfig} */
3
4
  module.exports = {
4
5
  plugins: ['stylelint-plugin-rhythmguard'],
5
6
  rules: {
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ /** @type {import('../../types/shared').RhythmguardStylelintConfig} */
3
4
  module.exports = {
4
5
  extends: [
5
6
  'stylelint-config-tailwindcss',
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
  const { cachedByFiles } = require('./fs-cache');
6
+ const { readJsonFile } = require('./json');
6
7
  const { numbersEqual, parseLengthToken, toPx } = require('./length');
7
8
 
8
9
  /**
@@ -105,7 +106,7 @@ function loadRcDecisions(cwd = process.cwd(), { baseFontSize = 16 } = {}) {
105
106
  }
106
107
  let config;
107
108
  try {
108
- config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
109
+ config = readJsonFile(rcPath);
109
110
  } catch {
110
111
  return [];
111
112
  }
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ const BYTE_ORDER_MARK = /^\uFEFF/;
6
+
7
+ /** Text without a leading UTF-8 byte order mark, which some Windows editors write and JSON.parse rejects (issue #291). */
8
+ function stripBom(text) {
9
+ return String(text).replace(BYTE_ORDER_MARK, '');
10
+ }
11
+
12
+ /** JSON.parse for files people edit: a leading byte order mark is tolerated. */
13
+ function parseJson(text) {
14
+ return JSON.parse(stripBom(text));
15
+ }
16
+
17
+ function readJsonFile(file) {
18
+ return parseJson(fs.readFileSync(file, 'utf8'));
19
+ }
20
+
21
+ module.exports = {
22
+ parseJson,
23
+ readJsonFile,
24
+ stripBom,
25
+ };
@@ -224,6 +224,16 @@ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy,
224
224
  return converted === null ? null : formatLength(converted, unit);
225
225
  }
226
226
 
227
+ /** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */
228
+ function pxAside(value, baseFontSize) {
229
+ const parsed = parseLengthToken(value);
230
+ if (!parsed || !parsed.unit || parsed.unit === 'px' || parsed.unit === '%') {
231
+ return '';
232
+ }
233
+ const px = toPx(Math.abs(parsed.number), parsed.unit, baseFontSize);
234
+ return px === null ? '' : ` (${formatLength(parsed.number < 0 ? -px : px, 'px')})`;
235
+ }
236
+
227
237
  module.exports = {
228
238
  nearestText,
229
239
  fixedLengthValue,
@@ -236,6 +246,7 @@ module.exports = {
236
246
  normalizeScaleByUnit,
237
247
  numbersEqual,
238
248
  parseLengthToken,
249
+ pxAside,
239
250
  pxOnScale,
240
251
  toPx,
241
252
  };
@@ -92,8 +92,14 @@ function isAutoScaleLiteral(value) {
92
92
  return value === 'auto';
93
93
  }
94
94
 
95
- function isScaleEntryOrAuto(value) {
96
- return isAutoScaleLiteral(value) || isScaleEntry(value);
95
+ /** A list of scale values. An empty list checks nothing, so it is not a scale (issue #273). */
96
+ function isScaleList(value) {
97
+ return Array.isArray(value) && value.length > 0 && value.every((entry) => isScaleEntry(entry));
98
+ }
99
+
100
+ /** A name `getScalePreset` knows, built-in or community, aliases included. */
101
+ function isKnownPresetName(value) {
102
+ return typeof value === 'string' && Boolean(getScalePreset(value));
97
103
  }
98
104
 
99
105
  function isScaleSourceEntry(value) {
@@ -213,9 +219,18 @@ function isMathFunctionArgumentMap(value) {
213
219
  });
214
220
  }
215
221
 
222
+ /**
223
+ * A scale given as values, a preset name, or a { preset | scale | customScale }
224
+ * object. An empty list or an unknown preset would leave the scale empty or
225
+ * fall back to the default without a word, so neither is valid (issue #273).
226
+ */
216
227
  function isScaleOverrideEntry(value) {
217
228
  if (Array.isArray(value)) {
218
- return value.every((entry) => isScaleEntry(entry));
229
+ return isScaleList(value);
230
+ }
231
+
232
+ if (typeof value === 'string') {
233
+ return isKnownPresetName(value);
219
234
  }
220
235
 
221
236
  if (!isPlainObject(value)) {
@@ -230,18 +245,15 @@ function isScaleOverrideEntry(value) {
230
245
  return false;
231
246
  }
232
247
 
233
- if (hasPreset && !isNonEmptyString(value.preset)) {
248
+ if (hasPreset && !isKnownPresetName(value.preset)) {
234
249
  return false;
235
250
  }
236
251
 
237
- if (hasScale && (!Array.isArray(value.scale) || !value.scale.every((entry) => isScaleEntry(entry)))) {
252
+ if (hasScale && !isScaleList(value.scale)) {
238
253
  return false;
239
254
  }
240
255
 
241
- if (
242
- hasCustomScale &&
243
- (!Array.isArray(value.customScale) || !value.customScale.every((entry) => isScaleEntry(entry)))
244
- ) {
256
+ if (hasCustomScale && !isScaleList(value.customScale)) {
245
257
  return false;
246
258
  }
247
259
 
@@ -267,13 +279,7 @@ const SCALED_GROUPS = Object.freeze(['spacing', 'radius', 'size', 'typography'])
267
279
 
268
280
  /** One group's scale: values, a preset name, "auto", or a { preset | scale | customScale } object. */
269
281
  function isGroupScaleEntry(value) {
270
- if (isAutoScaleLiteral(value)) {
271
- return true;
272
- }
273
- if (typeof value === 'string') {
274
- return Boolean(getScalePreset(value));
275
- }
276
- return isScaleOverrideEntry(value);
282
+ return isAutoScaleLiteral(value) || isScaleOverrideEntry(value);
277
283
  }
278
284
 
279
285
  function isGroupScalesMap(value) {
@@ -293,7 +299,7 @@ function buildGroupScales(scales) {
293
299
  if (isAutoScaleLiteral(entry)) {
294
300
  return [group, { auto: true, scale: null }];
295
301
  }
296
- const scale = typeof entry === 'string' ? getScalePreset(entry) : resolveScaleOverride(entry);
302
+ const scale = resolveScaleOverride(entry);
297
303
  return [group, { auto: false, scale: Array.isArray(scale) ? [...scale] : null }];
298
304
  }));
299
305
  }
@@ -302,7 +308,9 @@ function buildPossibleOptionMap(schema) {
302
308
  return Object.fromEntries(
303
309
  Object.entries(schema).map(([optionName, descriptor]) => [
304
310
  optionName,
305
- [descriptor.entryValidator || VALIDATE_ALWAYS],
311
+ // A literal such as "auto" stands for the whole option; validateSecondaryOptionShapes
312
+ // rejects it inside an array (issue #273).
313
+ [...(descriptor.allowLiterals || []), descriptor.entryValidator || VALIDATE_ALWAYS],
306
314
  ]),
307
315
  );
308
316
  }
@@ -359,6 +367,12 @@ function resolveScaleOverride(override) {
359
367
  return override;
360
368
  }
361
369
 
370
+ // A preset name, as `propertyScales: { padding: "rhythmic-8" }` (issue #273).
371
+ if (typeof override === 'string') {
372
+ const preset = getScalePreset(override);
373
+ return preset ? [...preset] : null;
374
+ }
375
+
362
376
  if (!isPlainObject(override)) {
363
377
  return null;
364
378
  }
@@ -438,6 +452,7 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
438
452
  customScale: Object.freeze({
439
453
  entryValidator: isScaleEntry,
440
454
  expectsArray: true,
455
+ nonEmpty: true,
441
456
  }),
442
457
  checkMathBounds: Object.freeze({
443
458
  entryValidator: isBoolean,
@@ -467,8 +482,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
467
482
  }),
468
483
  scale: Object.freeze({
469
484
  allowLiterals: ['auto'],
470
- entryValidator: isScaleEntryOrAuto,
485
+ entryValidator: isScaleEntry,
471
486
  expectsArray: true,
487
+ nonEmpty: true,
472
488
  }),
473
489
  scaleSources: Object.freeze({
474
490
  entryValidator: isScaleSourceEntry,
@@ -536,6 +552,7 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
536
552
  customScale: Object.freeze({
537
553
  entryValidator: isScaleEntry,
538
554
  expectsArray: true,
555
+ nonEmpty: true,
539
556
  }),
540
557
  checkMathBounds: Object.freeze({
541
558
  entryValidator: isBoolean,
@@ -579,8 +596,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
579
596
  }),
580
597
  scale: Object.freeze({
581
598
  allowLiterals: ['auto'],
582
- entryValidator: isScaleEntryOrAuto,
599
+ entryValidator: isScaleEntry,
583
600
  expectsArray: true,
601
+ nonEmpty: true,
584
602
  }),
585
603
  scaleSources: Object.freeze({
586
604
  entryValidator: isScaleSourceEntry,
@@ -809,6 +827,7 @@ module.exports = {
809
827
  PREFER_TOKEN_VALIDATION_SCHEMA,
810
828
  USE_SCALE_POSSIBLE_OPTIONS,
811
829
  USE_SCALE_VALIDATION_SCHEMA,
830
+ buildGroupScales,
812
831
  buildScaleOptions,
813
832
  buildTokenOptions,
814
833
  SCALED_GROUPS,
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
+ const { readJsonFile } = require('./json');
6
7
  const { parseLengthToken, toPx } = require('./length');
7
8
  const { buildEffectiveTokenMap } = require('./token-map');
8
9
  const {
@@ -18,7 +19,14 @@ const { getScalePreset } = require('../presets/scales');
18
19
  // Matches the audit default so lint and audit agree on what a spacing token is.
19
20
  const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing|spacer)(-|$)';
20
21
  // Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
21
- const TAILWIND_BASE_TOKENS = new Set(['--spacing', '--space']);
22
+ // A bare `--space` is not Tailwind's; it is one token like any other (issue #293).
23
+ const TAILWIND_BASE_TOKENS = new Set(['--spacing']);
24
+ // A bare `--space` is a base only when the other spacing tokens do not form a
25
+ // scale on their own. Discourse declares `--space: 0.25rem` and writes every
26
+ // step as `calc(var(--space) * N)`, so its scale is the multiples; a ladder of
27
+ // plain lengths (`--space-s`, `--space-m`) next to `--space` is the scale, and
28
+ // `--space` is one more step on it (issue #293).
29
+ const CONDITIONAL_BASE_TOKENS = new Set(['--space']);
22
30
  const TAILWIND_SPACING_MULTIPLIERS = [
23
31
  0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
24
32
  ];
@@ -38,7 +46,7 @@ const TOKEN_PACKAGES = require('./token-packages.json').packages;
38
46
  */
39
47
  function readDirectDependencies(dir) {
40
48
  try {
41
- const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
49
+ const pkg = readJsonFile(path.join(dir, 'package.json'));
42
50
  return new Set([
43
51
  ...Object.keys(pkg.dependencies || {}),
44
52
  ...Object.keys(pkg.devDependencies || {}),
@@ -171,13 +179,38 @@ function parseSourcesCached(sources, baseFontSize) {
171
179
  return warnings.length > 0 ? { ...parsed, warnings: [...warnings, ...parsed.warnings] } : parsed;
172
180
  }
173
181
 
182
+ /**
183
+ * Token sources read for the spacing names and, when a rule sets its own
184
+ * `tokenPattern` (`^--space-|^--radius-`), for that pattern too, on top of the
185
+ * spacing names, unless a source sets its own. Fixes and scale inference read
186
+ * the same tokens through this (issue #296).
187
+ */
188
+ function parseSourcesWithPattern(sources, baseFontSize, tokenPattern = null) {
189
+ const plain = parseSourcesCached(sources, baseFontSize);
190
+ if (!tokenPattern || !plain) {
191
+ return plain;
192
+ }
193
+ const withPattern = parseSourcesCached(
194
+ sources.map((source) => (typeof source === 'string' ? { path: source, tokenPattern } : { tokenPattern, ...source })),
195
+ baseFontSize,
196
+ );
197
+ const definitions = new Map(plain.definitions);
198
+ for (const [token, definition] of withPattern ? withPattern.definitions : []) {
199
+ if (!definitions.has(token)) {
200
+ definitions.set(token, definition);
201
+ }
202
+ }
203
+ const warnings = Array.from(new Set([...plain.warnings, ...(withPattern ? withPattern.warnings : [])]));
204
+ return { definitions, sources: plain.sources, warnings };
205
+ }
206
+
174
207
  /**
175
208
  * The scale a list of token sources defines, or null. Warnings from sources
176
209
  * that are missing or do not parse go into `warnings` either way, so a
177
210
  * fallback can say which source failed (issue #270).
178
211
  */
179
- function scaleFromSources(sources, baseFontSize, warnings = []) {
180
- const parsed = parseSourcesCached(sources, baseFontSize);
212
+ function scaleFromSources(sources, baseFontSize, warnings = [], tokenPattern = null) {
213
+ const parsed = parseSourcesWithPattern(sources, baseFontSize, tokenPattern);
181
214
  if (!parsed) {
182
215
  return null;
183
216
  }
@@ -203,16 +236,10 @@ function scaleFromSources(sources, baseFontSize, warnings = []) {
203
236
  function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenPattern = null, tokenRegex }) {
204
237
  const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
205
238
  const sources = [...scaleSources, ...rcTokenSources(process.cwd()).sources, ...discoverTokenPackages(process.cwd())];
206
- // A rule's own tokenPattern (`^--space-|^--radius-`) also applies to the
207
- // files it reads, on top of the spacing names, unless a source sets its own.
208
- const withPattern = tokenPattern
209
- ? sources.map((source) => (typeof source === 'string' ? { path: source, tokenPattern } : { tokenPattern, ...source }))
210
- : [];
211
- for (const parsed of [parseSourcesCached(sources, baseFontSize), parseSourcesCached(withPattern, baseFontSize)]) {
212
- for (const [token, definition] of parsed ? parsed.definitions : []) {
213
- if (!definitions.has(token)) {
214
- definitions.set(token, definition);
215
- }
239
+ const parsed = parseSourcesWithPattern(sources, baseFontSize, tokenPattern);
240
+ for (const [token, definition] of parsed ? parsed.definitions : []) {
241
+ if (!definitions.has(token)) {
242
+ definitions.set(token, definition);
216
243
  }
217
244
  }
218
245
  return definitions;
@@ -252,17 +279,36 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
252
279
  function scaleFromAllDefinitions(definitions, baseFontSize) {
253
280
  const keys = [];
254
281
  const baseKeys = [];
282
+ const conditionalKeys = [];
255
283
  for (const definition of definitions.values()) {
256
284
  if (TAILWIND_BASE_TOKENS.has(definition.token)) {
257
285
  baseKeys.push(...definition.normalizedValues);
258
286
  continue;
259
287
  }
288
+ if (CONDITIONAL_BASE_TOKENS.has(definition.token)) {
289
+ conditionalKeys.push(...definition.normalizedValues);
290
+ continue;
291
+ }
260
292
  keys.push(...definition.normalizedValues);
261
293
  }
262
- const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
294
+ const scale = scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys });
263
295
  return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
264
296
  }
265
297
 
298
+ /**
299
+ * The scale from plain token values, expanded by a base multiplier where one
300
+ * applies: `--spacing` always, a bare `--space` only when the plain values do
301
+ * not already form a scale (see CONDITIONAL_BASE_TOKENS).
302
+ */
303
+ function scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys }) {
304
+ const plain = pxValuesFromKeys(keys, baseFontSize);
305
+ if (baseKeys.length > 0 || plain.length >= MIN_INFERRED_SCALE_LENGTH || conditionalKeys.length === 0) {
306
+ const withConditional = conditionalKeys.length > 0 ? pxValuesFromKeys([...keys, ...conditionalKeys], baseFontSize) : plain;
307
+ return expandTailwindBase(withConditional, baseKeys, baseFontSize);
308
+ }
309
+ return expandTailwindBase(plain, conditionalKeys, baseFontSize);
310
+ }
311
+
266
312
  /** The scope of a declaration node: the AST-side twin of customPropertyDeclarations. */
267
313
  function scopeOfNode(decl) {
268
314
  for (let node = decl.parent; node; node = node.parent) {
@@ -302,8 +348,8 @@ function stylesheetDefinitions(root, tokenRegex, baseFontSize) {
302
348
  }
303
349
 
304
350
  /**
305
- * Merge Tailwind-style base multiples into a scale when a bare --spacing/--space
306
- * base is defined. Only the first base found is expanded: a project that ships
351
+ * Merge Tailwind-style base multiples into a scale when a bare --spacing base
352
+ * is defined. Only the first base found is expanded: a project that ships
307
353
  * several theme files with different bases (shadcn/ui) has one active base at a
308
354
  * time, and a union of ladders is a scale nobody designed (issue #89).
309
355
  */
@@ -354,7 +400,7 @@ function rcTokenSourcesUncached(rcPath) {
354
400
 
355
401
  let config;
356
402
  try {
357
- config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
403
+ config = readJsonFile(rcPath);
358
404
  } catch (err) {
359
405
  return { sources: [], warnings: [`${RC_FILE} could not be read: ${err.message}`] };
360
406
  }
@@ -400,39 +446,47 @@ function sassTokensFromRoot(root) {
400
446
  function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
401
447
  const keys = [...extraKeys];
402
448
  const baseKeys = [];
449
+ const conditionalKeys = [];
403
450
  for (const [key, reference] of Object.entries(map)) {
404
451
  const name = String(reference).match(/^var\((--[\w-]+)\)$/);
405
452
  if (name && TAILWIND_BASE_TOKENS.has(name[1])) {
406
453
  baseKeys.push(key);
454
+ } else if (name && CONDITIONAL_BASE_TOKENS.has(name[1])) {
455
+ conditionalKeys.push(key);
407
456
  } else {
408
457
  keys.push(key);
409
458
  }
410
459
  }
411
- const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
460
+ const scale = scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys });
412
461
  return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
413
462
  }
414
463
 
415
464
  /**
416
465
  * Resolve `scale: "auto"`. First matching source wins; sources are not merged so
417
- * the provenance is a single file list or the stylesheet.
466
+ * the provenance is a single file list or the stylesheet. The order, which
467
+ * docs/rules/use-scale.md documents: `scaleSources`, `.rhythmguardrc.json`
468
+ * token sources, the linted stylesheet, `tailwindConfigPath`, installed token
469
+ * packages, the fallback preset. `sourceTokenPattern` is the rule's own
470
+ * explicit `tokenPattern`, applied to the token files as fixes apply it.
418
471
  */
419
472
  function resolveAutoScale({
420
473
  baseFontSize = 16,
421
474
  root,
422
475
  scaleSources = [],
476
+ sourceTokenPattern = null,
423
477
  tailwindConfigPath = null,
424
478
  tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
425
479
  } = {}) {
426
480
  // Warnings from every source tried, for the fallback note when none yields a scale.
427
481
  const warnings = [];
428
- const fromOption = scaleFromSources(scaleSources, baseFontSize, warnings);
482
+ const fromOption = scaleFromSources(scaleSources, baseFontSize, warnings, sourceTokenPattern);
429
483
  if (fromOption) {
430
484
  return { source: 'scaleSources', ...fromOption };
431
485
  }
432
486
 
433
487
  const rc = rcTokenSources(process.cwd());
434
488
  warnings.push(...rc.warnings);
435
- const fromRc = scaleFromSources(rc.sources, baseFontSize, warnings);
489
+ const fromRc = scaleFromSources(rc.sources, baseFontSize, warnings, sourceTokenPattern);
436
490
  if (fromRc) {
437
491
  return { source: 'rhythmguardrc', ...fromRc };
438
492
  }
@@ -473,7 +527,7 @@ function resolveAutoScale({
473
527
  }
474
528
  }
475
529
 
476
- const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize, warnings);
530
+ const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize, warnings, sourceTokenPattern);
477
531
  if (fromPackages) {
478
532
  return { source: 'token-package', ...fromPackages };
479
533
  }
@@ -546,6 +600,7 @@ function withResolvedScale(options, root) {
546
600
  baseFontSize: options.baseFontSize,
547
601
  root,
548
602
  scaleSources: options.scaleSources,
603
+ sourceTokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
549
604
  tailwindConfigPath: options.tailwindConfigPath,
550
605
  tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
551
606
  });
@@ -172,8 +172,9 @@ function analyzeClassToken(token, options, scalePx) {
172
172
  return null;
173
173
  }
174
174
 
175
- // `-m-[16px]` and `m-[-16px]` are both negative margins (#241).
176
- const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
175
+ // `-m-[16px]` and `m-[-16px]` are both negative margins (#241); in
176
+ // `-m-[-16px]` the two signs cancel and Tailwind writes a positive margin (#277).
177
+ const negative = match.groups.utility.startsWith('-') !== parsedLength.number < 0;
177
178
 
178
179
  if (!options.allowNegative && negative) {
179
180
  return {
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const { readJsonFile } = require('./json');
3
4
  const { parseTokenValueLength } = require('./token-sources');
4
5
 
5
6
  const { spawnSync } = require('node:child_process');
@@ -140,7 +141,7 @@ function mergeTokenMapFromFile({
140
141
 
141
142
  let parsed;
142
143
  try {
143
- parsed = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
144
+ parsed = readJsonFile(resolvedPath);
144
145
  } catch {
145
146
  return currentMap;
146
147
  }
@@ -3,6 +3,7 @@
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
+ const { stripBom } = require('./json');
6
7
  const {
7
8
  formatLength,
8
9
  parseLengthToken,
@@ -116,7 +117,7 @@ function parseTokenSources({
116
117
 
117
118
  let text;
118
119
  try {
119
- text = fs.readFileSync(normalizedSource.resolvedPath, 'utf8');
120
+ text = stripBom(fs.readFileSync(normalizedSource.resolvedPath, 'utf8'));
120
121
  } catch (err) {
121
122
  const warning = `Unable to read token source ${normalizedSource.displayPath}: ${err.message}`;
122
123
  sourceReport.warnings.push(warning);
@@ -3,17 +3,23 @@
3
3
  const tailwindClassUseScale = require('./rules/tailwind-class-use-scale');
4
4
  const tailwindClassUseMotionScale = require('./rules/tailwind-class-use-motion-scale');
5
5
 
6
- module.exports = {
6
+ const plugin = {
7
7
  rules: {
8
8
  'tailwind-class-use-scale': tailwindClassUseScale,
9
9
  'tailwind-class-use-motion-scale': tailwindClassUseMotionScale,
10
10
  },
11
- configs: {
12
- recommended: {
13
- rules: {
14
- 'rhythmguard-tailwind/tailwind-class-use-scale': 'warn',
15
- 'rhythmguard-tailwind/tailwind-class-use-motion-scale': 'off',
16
- },
17
- },
11
+ configs: {},
12
+ };
13
+
14
+ // A flat config that works on its own (`export default [rhythmguard.configs.recommended]`),
15
+ // so it registers the plugin under the prefix its rules use (#279). Spreading
16
+ // `configs.recommended.rules` into a block that registers the plugin itself still works.
17
+ plugin.configs.recommended = {
18
+ plugins: { 'rhythmguard-tailwind': plugin },
19
+ rules: {
20
+ 'rhythmguard-tailwind/tailwind-class-use-scale': 'warn',
21
+ 'rhythmguard-tailwind/tailwind-class-use-motion-scale': 'off',
18
22
  },
19
23
  };
24
+
25
+ module.exports = plugin;
@@ -54,6 +54,7 @@ function buildMessage(analysis, segment, lower, upper) {
54
54
 
55
55
  module.exports = {
56
56
  meta: {
57
+ type: 'suggestion',
57
58
  docs: {
58
59
  url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-motion-scale.md',
59
60
  description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',