stylelint-plugin-rhythmguard 2.1.0 → 3.0.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 (39) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/CONTRIBUTING.md +66 -50
  3. package/README.md +17 -6
  4. package/SECURITY.md +2 -2
  5. package/package.json +27 -27
  6. package/scales/community/product-decimal-10.json +27 -4
  7. package/src/audit/args.js +6 -1
  8. package/src/audit/config.js +15 -5
  9. package/src/audit/contract.js +20 -0
  10. package/src/audit/render-markdown.js +13 -0
  11. package/src/audit/render-text.js +9 -1
  12. package/src/audit/report.js +105 -8
  13. package/src/audit/scan.js +67 -10
  14. package/src/cli/index.js +11 -5
  15. package/src/cli/init.js +7 -4
  16. package/src/cli/quickstart.js +207 -0
  17. package/src/configs/embed.js +24 -0
  18. package/src/configs/{logical.mjs → embed.mjs} +1 -1
  19. package/src/index.js +1 -4
  20. package/src/rules/no-offscale-transform/index.js +22 -0
  21. package/src/rules/prefer-token/index.js +28 -0
  22. package/src/rules/use-scale/index.js +34 -4
  23. package/src/utils/length.js +16 -0
  24. package/src/utils/options.js +53 -3
  25. package/src/utils/scale-inference.js +351 -0
  26. package/src/utils/token-map.js +4 -2
  27. package/src/utils/token-packages.json +48 -0
  28. package/src/utils/token-sources.js +313 -4
  29. package/types/__checks__/consumer.ts +10 -6
  30. package/types/audit.d.ts +21 -1
  31. package/types/index.d.ts +4 -4
  32. package/types/shared.d.ts +24 -1
  33. package/src/configs/expanded.js +0 -27
  34. package/src/configs/expanded.mjs +0 -4
  35. package/src/configs/logical.js +0 -16
  36. package/src/configs/migration.js +0 -31
  37. package/src/configs/migration.mjs +0 -4
  38. package/src/configs/react-tailwind.js +0 -25
  39. package/src/configs/react-tailwind.mjs +0 -4
@@ -5,6 +5,7 @@ const valueParser = require('postcss-value-parser');
5
5
  const {
6
6
  formatLength,
7
7
  fromPx,
8
+ isHairlineLength,
8
9
  nearestScaleValues,
9
10
  normalizeScale,
10
11
  normalizeScaleByUnit,
@@ -29,13 +30,19 @@ const {
29
30
  walkTransformTranslateNodes,
30
31
  } = require('../../utils/value-utils');
31
32
 
33
+ const {
34
+ DEFAULT_AUTO_TOKEN_PATTERN,
35
+ autoScaleFallbackNote,
36
+ resolveAutoScale,
37
+ } = require('../../utils/scale-inference');
38
+
32
39
  const ruleName = 'rhythmguard/use-scale';
33
40
 
34
41
  const messages = stylelint.utils.ruleMessages(ruleName, {
35
42
  invalidPreset: (presetName, presetNames) =>
36
43
  `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
37
- rejected: (value, lower, upper) =>
38
- `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).`,
44
+ rejected: (value, lower, upper, note = '') =>
45
+ `Unexpected off-scale value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
39
46
  });
40
47
 
41
48
  function getFixedNodeValue(parsedLength, nearestPx, options) {
@@ -82,7 +89,16 @@ function checkLengthValue({
82
89
  return false;
83
90
  }
84
91
 
85
- if (parsedLength.unit === '%' && options.allowPercentages) {
92
+ if (parsedLength.unit === '%') {
93
+ if (options.allowPercentages) {
94
+ return false;
95
+ }
96
+
97
+ report(node.value, decl, node, null, null, '%');
98
+ return false;
99
+ }
100
+
101
+ if (options.allowHairlines && isHairlineLength(parsedLength, options.baseFontSize)) {
86
102
  return false;
87
103
  }
88
104
 
@@ -180,8 +196,21 @@ const ruleFunction = (primary, secondaryOptions) => {
180
196
  });
181
197
  }
182
198
 
199
+ if (options.scaleAuto) {
200
+ const inference = resolveAutoScale({
201
+ baseFontSize: options.baseFontSize,
202
+ root,
203
+ scaleSources: options.scaleSources,
204
+ tailwindConfigPath: options.tailwindConfigPath,
205
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
206
+ });
207
+ options.scale = inference.scale;
208
+ options.scaleInference = inference;
209
+ }
210
+
183
211
  const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
184
212
  const scaleCache = new Map();
213
+ let fallbackNote = autoScaleFallbackNote(options.scaleInference);
185
214
 
186
215
  const getScaleStateForProperty = (prop) => {
187
216
  const cached = scaleCache.get(prop);
@@ -208,7 +237,7 @@ const ruleFunction = (primary, secondaryOptions) => {
208
237
  const payload = {
209
238
  endIndex,
210
239
  index,
211
- message: messages.rejected(value, lower, upper),
240
+ message: messages.rejected(value, lower, upper, fallbackNote),
212
241
  node: decl,
213
242
  result,
214
243
  ruleName,
@@ -221,6 +250,7 @@ const ruleFunction = (primary, secondaryOptions) => {
221
250
  };
222
251
  }
223
252
 
253
+ fallbackNote = '';
224
254
  stylelint.utils.report(payload);
225
255
  };
226
256
 
@@ -25,6 +25,21 @@ function parseLengthToken(rawValue) {
25
25
  return { number, raw: value, unit };
26
26
  }
27
27
 
28
+ /**
29
+ * A hairline is a non-zero length that resolves to one CSS pixel or less:
30
+ * 1px, -1px, 0.5px, 0.0625rem. Such values compensate for a border width or a
31
+ * rendering quirk; they are not spacing decisions, so the scale rules exempt
32
+ * them by default (`allowHairlines`).
33
+ */
34
+ function isHairlineLength(parsedLength, baseFontSize) {
35
+ if (!parsedLength || parsedLength.number === 0 || parsedLength.unit === '%') {
36
+ return false;
37
+ }
38
+
39
+ const px = toPx(Math.abs(parsedLength.number), parsedLength.unit || 'px', baseFontSize);
40
+ return px !== null && px > 0 && px <= 1;
41
+ }
42
+
28
43
  function toPx(number, unit, baseFontSize) {
29
44
  if (unit === '' || unit === 'px') {
30
45
  return number;
@@ -168,6 +183,7 @@ function nearestScaleValues(target, scale) {
168
183
  module.exports = {
169
184
  formatLength,
170
185
  fromPx,
186
+ isHairlineLength,
171
187
  nearestScaleValues,
172
188
  normalizeScale,
173
189
  normalizeScaleByUnit,
@@ -65,6 +65,22 @@ function isUnitStrategy(value) {
65
65
  return value === 'convert' || value === 'exact';
66
66
  }
67
67
 
68
+ function isAutoScaleLiteral(value) {
69
+ return value === 'auto';
70
+ }
71
+
72
+ function isScaleEntryOrAuto(value) {
73
+ return isAutoScaleLiteral(value) || isScaleEntry(value);
74
+ }
75
+
76
+ function isScaleSourceEntry(value) {
77
+ if (isNonEmptyString(value)) {
78
+ return true;
79
+ }
80
+
81
+ return isPlainObject(value) && (isNonEmptyString(value.path) || isNonEmptyString(value.file));
82
+ }
83
+
68
84
  function isScaleEntry(value) {
69
85
  if (typeof value === 'number') {
70
86
  return Number.isFinite(value) && value >= 0;
@@ -237,7 +253,10 @@ function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schem
237
253
  continue;
238
254
  }
239
255
 
240
- if (descriptor.expectsArray && !Array.isArray(optionValue)) {
256
+ const literalAllowed = Array.isArray(descriptor.allowLiterals)
257
+ && descriptor.allowLiterals.includes(optionValue);
258
+
259
+ if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
241
260
  valid = false;
242
261
  result.warn(
243
262
  `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
@@ -412,6 +431,9 @@ function resolveUnits(options) {
412
431
  }
413
432
 
414
433
  const SCALE_VALIDATION_SCHEMA = Object.freeze({
434
+ allowHairlines: Object.freeze({
435
+ entryValidator: isBoolean,
436
+ }),
415
437
  allowNegative: Object.freeze({
416
438
  entryValidator: isBoolean,
417
439
  }),
@@ -443,9 +465,17 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
443
465
  entryValidator: isNonEmptyString,
444
466
  }),
445
467
  scale: Object.freeze({
446
- entryValidator: isScaleEntry,
468
+ allowLiterals: ['auto'],
469
+ entryValidator: isScaleEntryOrAuto,
447
470
  expectsArray: true,
448
471
  }),
472
+ scaleSources: Object.freeze({
473
+ entryValidator: isScaleSourceEntry,
474
+ expectsArray: true,
475
+ }),
476
+ tailwindConfigPath: Object.freeze({
477
+ entryValidator: isNonEmptyString,
478
+ }),
449
479
  unitStrategy: Object.freeze({
450
480
  entryValidator: isUnitStrategy,
451
481
  }),
@@ -487,6 +517,9 @@ const NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA = Object.freeze({
487
517
  });
488
518
 
489
519
  const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
520
+ allowHairlines: Object.freeze({
521
+ entryValidator: isBoolean,
522
+ }),
490
523
  allowNumericScale: Object.freeze({
491
524
  entryValidator: isBoolean,
492
525
  }),
@@ -528,7 +561,12 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
528
561
  expectsObject: true,
529
562
  }),
530
563
  scale: Object.freeze({
531
- entryValidator: isScaleEntry,
564
+ allowLiterals: ['auto'],
565
+ entryValidator: isScaleEntryOrAuto,
566
+ expectsArray: true,
567
+ }),
568
+ scaleSources: Object.freeze({
569
+ entryValidator: isScaleSourceEntry,
532
570
  expectsArray: true,
533
571
  }),
534
572
  tailwindConfigPath: Object.freeze({
@@ -578,6 +616,7 @@ function buildScaleOptions(rawOptions) {
578
616
  const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
579
617
 
580
618
  return {
619
+ allowHairlines: options.allowHairlines !== false,
581
620
  allowNegative: options.allowNegative !== false,
582
621
  allowPercentages: options.allowPercentages !== false,
583
622
  baseFontSize:
@@ -600,6 +639,13 @@ function buildScaleOptions(rawOptions) {
600
639
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
601
640
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
602
641
  scale: scaleSelection.scale,
642
+ scaleAuto: isAutoScaleLiteral(options.scale),
643
+ scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
644
+ tailwindConfigPath:
645
+ typeof options.tailwindConfigPath === 'string' && options.tailwindConfigPath.length > 0
646
+ ? options.tailwindConfigPath
647
+ : null,
648
+ tokenPatternExplicit: typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0,
603
649
  tokenFunctions: Array.isArray(options.tokenFunctions)
604
650
  ? options.tokenFunctions.map((value) => String(value).toLowerCase())
605
651
  : ['var', 'theme', 'token'],
@@ -617,6 +663,7 @@ function buildTokenOptions(rawOptions) {
617
663
  const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
618
664
 
619
665
  return {
666
+ allowHairlines: options.allowHairlines !== false,
620
667
  allowNumericScale: options.allowNumericScale === true,
621
668
  baseFontSize:
622
669
  typeof options.baseFontSize === 'number' &&
@@ -637,6 +684,9 @@ function buildTokenOptions(rawOptions) {
637
684
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
638
685
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
639
686
  scale: scaleSelection.scale,
687
+ scaleAuto: isAutoScaleLiteral(options.scale),
688
+ scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
689
+ tokenPatternExplicit: typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0,
640
690
  tailwindConfigPath:
641
691
  typeof options.tailwindConfigPath === 'string' && options.tailwindConfigPath.length > 0
642
692
  ? options.tailwindConfigPath
@@ -0,0 +1,351 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const { parseLengthToken, toPx } = require('./length');
7
+ const { buildEffectiveTokenMap } = require('./token-map');
8
+ const { collectScssTokens, createTokenKindMatcher, parseTokenSources } = require('./token-sources');
9
+ const { getScalePreset } = require('../presets/scales');
10
+
11
+ // Matches the audit default so lint and audit agree on what a spacing token is.
12
+ const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing)(-|$)';
13
+ // Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
14
+ const TAILWIND_BASE_TOKENS = new Set(['--spacing', '--space']);
15
+ const TAILWIND_SPACING_MULTIPLIERS = [
16
+ 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,
17
+ ];
18
+ const FALLBACK_PRESET = 'rhythmic-4';
19
+ // Zero plus at least three distinct token values; a one- or two-token scale is worse than the default.
20
+ const MIN_INFERRED_SCALE_LENGTH = 4;
21
+ const RC_FILE = '.rhythmguardrc.json';
22
+
23
+ const sourceCache = new Map();
24
+ const TOKEN_PACKAGES = require('./token-packages.json').packages;
25
+
26
+ /**
27
+ * Installed design-token packages that ship a spacing scale (allowlist in
28
+ * token-packages.json). Resolved from the project, so only what the project
29
+ * actually depends on is read. Returns token-source entries.
30
+ */
31
+ function readDirectDependencies(dir) {
32
+ try {
33
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
34
+ return new Set([
35
+ ...Object.keys(pkg.dependencies || {}),
36
+ ...Object.keys(pkg.devDependencies || {}),
37
+ ...Object.keys(pkg.peerDependencies || {}),
38
+ ]);
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Project roots from cwd up to the repository boundary (first directory holding
46
+ * .git), each with the dependencies it declares directly. Hoisted monorepo
47
+ * installs are found by walking up; a stray global node_modules is never
48
+ * consulted because the walk stops at the repository.
49
+ */
50
+ function projectRoots(cwd) {
51
+ const roots = [];
52
+ let current = path.resolve(cwd);
53
+ for (;;) {
54
+ const direct = readDirectDependencies(current);
55
+ if (direct) {
56
+ roots.push({ dir: current, direct });
57
+ }
58
+ const parent = path.dirname(current);
59
+ if (fs.existsSync(path.join(current, '.git')) || parent === current) {
60
+ break;
61
+ }
62
+ current = parent;
63
+ }
64
+ return roots;
65
+ }
66
+
67
+ function discoverTokenPackages(cwd = process.cwd()) {
68
+ const sources = [];
69
+ const roots = projectRoots(cwd);
70
+ for (const entry of TOKEN_PACKAGES) {
71
+ // Only packages the project depends on directly count. A transitive
72
+ // tailwindcss (for example via stylelint-config-tailwindcss) must not hand
73
+ // a non-Tailwind project the Tailwind scale.
74
+ const owner = roots.find((root) => root.direct.has(entry.name));
75
+ if (!owner) {
76
+ continue;
77
+ }
78
+ const root = roots
79
+ .map((candidate) => path.join(candidate.dir, 'node_modules', entry.name))
80
+ .find((dir) => fs.existsSync(path.join(dir, 'package.json')));
81
+ if (!root) {
82
+ continue;
83
+ }
84
+ for (const file of entry.files) {
85
+ const resolved = path.join(root, file);
86
+ if (fs.existsSync(resolved)) {
87
+ sources.push({
88
+ format: 'auto',
89
+ package: entry.name,
90
+ path: resolved,
91
+ ...(entry.tokenPattern ? { tokenPattern: entry.tokenPattern } : {}),
92
+ });
93
+ }
94
+ }
95
+ }
96
+ return sources;
97
+ }
98
+
99
+ function pxValuesFromKeys(keys, baseFontSize) {
100
+ const values = new Set([0]);
101
+
102
+ for (const key of keys) {
103
+ const parsed = parseLengthToken(String(key));
104
+ if (!parsed) {
105
+ continue;
106
+ }
107
+
108
+ const px = toPx(Math.abs(parsed.number), parsed.unit || 'px', baseFontSize);
109
+ if (px !== null && Number.isFinite(px)) {
110
+ values.add(px);
111
+ }
112
+ }
113
+
114
+ return Array.from(values).sort((a, b) => a - b);
115
+ }
116
+
117
+ function normalizeSource(source, baseDir) {
118
+ if (typeof source === 'string') {
119
+ return { format: 'auto', path: path.resolve(baseDir, source) };
120
+ }
121
+
122
+ if (source && typeof source === 'object') {
123
+ const rawPath = source.path || source.file;
124
+ if (typeof rawPath !== 'string') {
125
+ return null;
126
+ }
127
+
128
+ return {
129
+ format: typeof source.format === 'string' ? source.format : 'auto',
130
+ path: path.resolve(source.baseDir || baseDir, rawPath),
131
+ ...(source.tokenPattern ? { tokenPattern: source.tokenPattern } : {}),
132
+ };
133
+ }
134
+
135
+ return null;
136
+ }
137
+
138
+ function cacheKey(sources) {
139
+ return sources
140
+ .map((source) => {
141
+ let mtime = 'missing';
142
+ try {
143
+ mtime = String(fs.statSync(source.path).mtimeMs);
144
+ } catch {
145
+ // missing file: key still changes when it appears
146
+ }
147
+ return `${source.path}|${source.format}|${source.tokenPattern || ''}|${mtime}`;
148
+ })
149
+ .join('\n');
150
+ }
151
+
152
+ function scaleFromSources(sources, baseFontSize) {
153
+ const normalized = sources.map((source) => normalizeSource(source, process.cwd())).filter(Boolean);
154
+ if (normalized.length === 0) {
155
+ return null;
156
+ }
157
+
158
+ const key = `${baseFontSize}\n${cacheKey(normalized)}`;
159
+ if (sourceCache.has(key)) {
160
+ return sourceCache.get(key);
161
+ }
162
+
163
+ const parsed = parseTokenSources({ baseFontSize, sources: normalized, tokenKind: 'spacing' });
164
+ // scaleFromDefinitions also expands a bare Tailwind --spacing base into its multiples.
165
+ const scale = scaleFromDefinitions(parsed.definitions, baseFontSize);
166
+ const outcome = scale
167
+ ? {
168
+ files: parsed.sources.map((source) => source.file),
169
+ scale,
170
+ tokenCount: parsed.definitions.size,
171
+ warnings: parsed.warnings,
172
+ }
173
+ : null;
174
+
175
+ sourceCache.set(key, outcome);
176
+ return outcome;
177
+ }
178
+
179
+ /** Build a px scale from token definitions as produced by token-sources.js / contract.js. */
180
+ function scaleFromDefinitions(definitions, baseFontSize = 16) {
181
+ const keys = [];
182
+ const baseKeys = [];
183
+ for (const definition of definitions.values()) {
184
+ if (TAILWIND_BASE_TOKENS.has(definition.token)) {
185
+ baseKeys.push(...definition.normalizedValues);
186
+ continue;
187
+ }
188
+ keys.push(...definition.normalizedValues);
189
+ }
190
+ const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
191
+ return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
192
+ }
193
+
194
+ /** Merge Tailwind-style base multiples into a scale when a bare --spacing/--space base is defined. */
195
+ function expandTailwindBase(scale, baseKeys, baseFontSize) {
196
+ const bases = pxValuesFromKeys(baseKeys, baseFontSize).filter((value) => value > 0);
197
+ if (bases.length === 0) {
198
+ return scale;
199
+ }
200
+ const values = new Set(scale);
201
+ for (const base of bases) {
202
+ for (const multiplier of TAILWIND_SPACING_MULTIPLIERS) {
203
+ values.add(Math.round(base * multiplier * 1000) / 1000);
204
+ }
205
+ }
206
+ return Array.from(values).sort((a, b) => a - b);
207
+ }
208
+
209
+ function rcTokenSources(cwd) {
210
+ const rcPath = path.join(cwd, RC_FILE);
211
+ if (!fs.existsSync(rcPath)) {
212
+ return [];
213
+ }
214
+
215
+ let config;
216
+ try {
217
+ config = JSON.parse(fs.readFileSync(rcPath, 'utf8'));
218
+ } catch {
219
+ return [];
220
+ }
221
+
222
+ const audit = config && typeof config === 'object' ? config.audit : null;
223
+ const sources = audit && Array.isArray(audit.tokenSources) ? audit.tokenSources : [];
224
+ const baseDir = path.dirname(rcPath);
225
+
226
+ return sources
227
+ .map((source) => normalizeSource(source, baseDir))
228
+ .filter(Boolean);
229
+ }
230
+
231
+ /**
232
+ * Sass variables and maps declared in the linted stylesheet itself (postcss-scss
233
+ * exposes them as declarations whose prop starts with `$`). Evaluated with the
234
+ * same collector the audit uses; component variables such as $dropdown-spacer
235
+ * are excluded by the anchored name rule.
236
+ */
237
+ function sassValuesFromRoot(root) {
238
+ const lines = [];
239
+ root.walkDecls((decl) => {
240
+ if (typeof decl.prop === 'string' && decl.prop.startsWith('$')) {
241
+ lines.push(`${decl.prop}: ${decl.value};`);
242
+ }
243
+ });
244
+ if (lines.length === 0) {
245
+ return [];
246
+ }
247
+ return collectScssTokens(lines.join('\n'), createTokenKindMatcher('spacing')).map((token) => token.value);
248
+ }
249
+
250
+ function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
251
+ const keys = [...extraKeys];
252
+ const baseKeys = [];
253
+ for (const [key, reference] of Object.entries(map)) {
254
+ const name = String(reference).match(/^var\((--[\w-]+)\)$/);
255
+ if (name && TAILWIND_BASE_TOKENS.has(name[1])) {
256
+ baseKeys.push(key);
257
+ } else {
258
+ keys.push(key);
259
+ }
260
+ }
261
+ const scale = expandTailwindBase(pxValuesFromKeys(keys, baseFontSize), baseKeys, baseFontSize);
262
+ return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
263
+ }
264
+
265
+ /**
266
+ * Resolve `scale: "auto"`. First matching source wins; sources are not merged so
267
+ * the provenance is a single file list or the stylesheet.
268
+ */
269
+ function resolveAutoScale({
270
+ baseFontSize = 16,
271
+ root,
272
+ scaleSources = [],
273
+ tailwindConfigPath = null,
274
+ tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
275
+ } = {}) {
276
+ const fromOption = scaleFromSources(scaleSources, baseFontSize);
277
+ if (fromOption) {
278
+ return { source: 'scaleSources', ...fromOption };
279
+ }
280
+
281
+ const fromRc = scaleFromSources(rcTokenSources(process.cwd()), baseFontSize);
282
+ if (fromRc) {
283
+ return { source: 'rhythmguardrc', ...fromRc };
284
+ }
285
+
286
+ let tokenRegex;
287
+ try {
288
+ tokenRegex = new RegExp(tokenPattern);
289
+ } catch {
290
+ tokenRegex = new RegExp(DEFAULT_AUTO_TOKEN_PATTERN);
291
+ }
292
+
293
+ if (root) {
294
+ const stylesheetMap = buildEffectiveTokenMap({
295
+ options: { baseFontSize, tokenMap: {}, tokenMapFromCssCustomProperties: true },
296
+ root,
297
+ tokenRegex,
298
+ });
299
+ const scale = scaleFromTokenMap(stylesheetMap, baseFontSize, sassValuesFromRoot(root));
300
+ if (scale) {
301
+ return { files: [], scale, source: 'stylesheet', tokenCount: scale.length - 1, warnings: [] };
302
+ }
303
+ }
304
+
305
+ if (tailwindConfigPath) {
306
+ const tailwindMap = buildEffectiveTokenMap({
307
+ options: {
308
+ baseFontSize,
309
+ tailwindConfigPath,
310
+ tokenMap: {},
311
+ tokenMapFromTailwindSpacing: true,
312
+ },
313
+ root,
314
+ tokenRegex,
315
+ });
316
+ const scale = scaleFromTokenMap(tailwindMap, baseFontSize);
317
+ if (scale) {
318
+ return { files: [tailwindConfigPath], scale, source: 'tailwind', tokenCount: scale.length - 1, warnings: [] };
319
+ }
320
+ }
321
+
322
+ const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize);
323
+ if (fromPackages) {
324
+ return { source: 'token-package', ...fromPackages };
325
+ }
326
+
327
+ return {
328
+ files: [],
329
+ preset: FALLBACK_PRESET,
330
+ scale: getScalePreset(FALLBACK_PRESET),
331
+ source: 'fallback',
332
+ tokenCount: 0,
333
+ warnings: [],
334
+ };
335
+ }
336
+
337
+ function autoScaleFallbackNote(inference) {
338
+ if (!inference || inference.source !== 'fallback') {
339
+ return '';
340
+ }
341
+
342
+ return `No spacing tokens were found for scale "auto"; using preset "${inference.preset}".`;
343
+ }
344
+
345
+ module.exports = {
346
+ DEFAULT_AUTO_TOKEN_PATTERN,
347
+ autoScaleFallbackNote,
348
+ discoverTokenPackages,
349
+ resolveAutoScale,
350
+ scaleFromDefinitions,
351
+ };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { parseTokenValueLength } = require('./token-sources');
4
+
3
5
  const { spawnSync } = require('node:child_process');
4
6
  const fs = require('node:fs');
5
7
  const path = require('node:path');
@@ -43,7 +45,7 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
43
45
  return;
44
46
  }
45
47
 
46
- const parsed = parseLengthToken(rawLength);
48
+ const parsed = parseTokenValueLength(rawLength);
47
49
  if (!parsed) {
48
50
  return;
49
51
  }
@@ -196,7 +198,7 @@ function mergeTokenMapFromCssCustomProperties({
196
198
  return;
197
199
  }
198
200
 
199
- const parsed = parseLengthToken(decl.value);
201
+ const parsed = parseTokenValueLength(decl.value);
200
202
  if (!parsed || parsed.number === 0) {
201
203
  return;
202
204
  }
@@ -0,0 +1,48 @@
1
+ {
2
+ "$comment": "Design-token packages whose installed files declare a spacing scale. Used by scale: \"auto\" after the project's own stylesheets are checked. Each entry lists the files to read (relative to the package root) and, when the package does not use space/spacing names, the token pattern to match. Additions welcome; keep them to files that ship in the published package.",
3
+ "packages": [
4
+ {
5
+ "name": "tailwindcss",
6
+ "files": [
7
+ "theme.css"
8
+ ],
9
+ "note": "Tailwind v4 base multiplier --spacing"
10
+ },
11
+ {
12
+ "name": "@radix-ui/themes",
13
+ "files": [
14
+ "tokens.css"
15
+ ],
16
+ "note": "--space-1..9 as calc(<px> * var(--scaling))"
17
+ },
18
+ {
19
+ "name": "@mantine/core",
20
+ "files": [
21
+ "styles.css"
22
+ ],
23
+ "note": "--mantine-spacing-* as calc(<rem> * var(--mantine-scale))"
24
+ },
25
+ {
26
+ "name": "@primer/primitives",
27
+ "files": [
28
+ "dist/css/base/size/size.css"
29
+ ],
30
+ "tokenPattern": "^--base-size-\\d+$",
31
+ "note": "Primer names its spacing ladder size"
32
+ },
33
+ {
34
+ "name": "@shopify/polaris-tokens",
35
+ "files": [
36
+ "dist/css/styles.css"
37
+ ],
38
+ "note": "--p-space-* in rem"
39
+ },
40
+ {
41
+ "name": "@spectrum-css/tokens",
42
+ "files": [
43
+ "dist/css/global-vars.css"
44
+ ],
45
+ "note": "--spectrum-spacing-* in px"
46
+ }
47
+ ]
48
+ }