stylelint-plugin-rhythmguard 3.8.0 → 3.10.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,216 @@
1
+ 'use strict';
2
+
3
+ const valueParser = require('postcss-value-parser');
4
+ const { formatLength, numbersEqual, parseLengthToken, toPx } = require('../core/length');
5
+
6
+ /**
7
+ * Token ladders (issue #205). A ladder is a set of custom properties that
8
+ * share a prefix and differ by a size step: `--font-size-text-xxs` to
9
+ * `-xxl`, `--space-1` to `--space-9`, `--radius-sm/md/lg`. Every step can be
10
+ * on the scale and the ladder still be broken, so each step is evaluated at
11
+ * its smallest and largest value (a static length, a clamp() minimum and
12
+ * maximum, the static arguments of min() and max(), followed through var())
13
+ * and adjacent steps are compared at both ends:
14
+ *
15
+ * collapsed two steps render the same size at one end
16
+ * too-close two steps differ by less than `minStepPx` (default 2px)
17
+ * inverted a step is larger than the next one at one end
18
+ *
19
+ * Each problem names the end it occurs at: `min`, `max`, or `both`.
20
+ *
21
+ * A step that cannot be evaluated (calc(), a viewport length, a token with
22
+ * several different definitions) is left out of the comparison.
23
+ */
24
+ const DEFAULT_MIN_STEP_PX = 2;
25
+ const MIN_LADDER_STEPS = 3;
26
+ const VAR_REFERENCE = /^var\(\s*(--[\w-]+)\s*(?:,\s*([\s\S]+))?\)$/i;
27
+
28
+ // T-shirt steps in order. Aliases share a rank (sm and s, 2xl and xxl).
29
+ const SIZE_RANKS = new Map([
30
+ ['xxxs', -4], ['3xs', -4], ['xxs', -3], ['2xs', -3], ['xs', -2],
31
+ ['s', -1], ['sm', -1], ['small', -1],
32
+ ['m', 0], ['md', 0], ['medium', 0], ['base', 0],
33
+ ['l', 1], ['lg', 1], ['large', 1],
34
+ ['xl', 2], ['xxl', 3], ['2xl', 3], ['xxxl', 4], ['3xl', 4],
35
+ ['4xl', 5], ['5xl', 6], ['6xl', 7], ['7xl', 8], ['8xl', 9], ['9xl', 10],
36
+ ]);
37
+ const STEP_SUFFIX = /^(--[\w-]+?)-([a-z0-9]+)$/i;
38
+
39
+ /** The ladder a token belongs to and its rank on it, or null. */
40
+ function ladderStep(token) {
41
+ const match = token.match(STEP_SUFFIX);
42
+ if (!match) {
43
+ return null;
44
+ }
45
+ const [, prefix, suffix] = match;
46
+ const lower = suffix.toLowerCase();
47
+ if (SIZE_RANKS.has(lower)) {
48
+ return { key: `${prefix} (sizes)`, prefix, rank: SIZE_RANKS.get(lower) };
49
+ }
50
+ if (/^\d+$/.test(lower)) {
51
+ return { key: `${prefix} (numbers)`, prefix, rank: Number(lower) };
52
+ }
53
+ return null;
54
+ }
55
+
56
+ function splitArguments(fnNode) {
57
+ const args = [[]];
58
+ for (const node of fnNode.nodes) {
59
+ if (node.type === 'div' && node.value === ',') {
60
+ args.push([]);
61
+ } else {
62
+ args[args.length - 1].push(node);
63
+ }
64
+ }
65
+ return args.map((nodes) => valueParser.stringify(nodes).trim());
66
+ }
67
+
68
+ function createEvaluator(declarations, baseFontSize) {
69
+ const memo = new Map();
70
+
71
+ const evaluateToken = (token, seen) => {
72
+ if (memo.has(token)) return memo.get(token);
73
+ const values = declarations.get(token);
74
+ if (!values || seen.has(token)) return null;
75
+ const ranges = [...values].map((value) => evaluate(value, new Set([...seen, token])));
76
+ const [first] = ranges;
77
+ const agree = first && ranges.every((range) => range && sameEnd(range.min, first.min) && sameEnd(range.max, first.max));
78
+ const result = agree ? first : null;
79
+ memo.set(token, result);
80
+ return result;
81
+ };
82
+
83
+ // { min, max } in px; an end is null when the value has no static bound there.
84
+ const evaluate = (raw, seen = new Set()) => {
85
+ const value = String(raw).trim();
86
+ const reference = value.match(VAR_REFERENCE);
87
+ if (reference) {
88
+ const [, target, fallback] = reference;
89
+ if (declarations.has(target)) return evaluateToken(target, seen);
90
+ return fallback === undefined ? null : evaluate(fallback, seen);
91
+ }
92
+ const parsed = valueParser(value).nodes.filter((node) => node.type !== 'space');
93
+ if (parsed.length !== 1) return null;
94
+ const [node] = parsed;
95
+ if (node.type === 'word') {
96
+ const length = parseLengthToken(node.value);
97
+ if (!length || !length.unit || length.unit === '%') return null;
98
+ const px = toPx(Math.abs(length.number), length.unit, baseFontSize);
99
+ if (px === null) return null;
100
+ const signed = length.number < 0 ? -px : px;
101
+ return { max: signed, min: signed };
102
+ }
103
+ if (node.type !== 'function') return null;
104
+ const name = node.value.toLowerCase();
105
+ const args = splitArguments(node).map((arg) => evaluate(arg, seen));
106
+ if (name === 'clamp') {
107
+ if (args.length !== 3 || !args[0] || !args[2] || args[0].min === null || args[2].max === null) return null;
108
+ return { max: args[2].max, min: args[0].min };
109
+ }
110
+ if (name === 'min' || name === 'max') {
111
+ const statics = args.filter((range) => range && range.min !== null && range.max !== null);
112
+ if (statics.length === 0) return null;
113
+ const pick = name === 'min' ? Math.min : Math.max;
114
+ const all = statics.length === args.length;
115
+ // min(5vw, 2rem) is at most 2rem and has no static minimum; max() mirrors it.
116
+ return {
117
+ max: name === 'min' || all ? pick(...statics.map((range) => range.max)) : null,
118
+ min: name === 'max' || all ? pick(...statics.map((range) => range.min)) : null,
119
+ };
120
+ }
121
+ return null;
122
+ };
123
+
124
+ return { evaluate, evaluateToken };
125
+ }
126
+
127
+ function sameEnd(a, b) {
128
+ return (a === null && b === null) || (a !== null && b !== null && numbersEqual(a, b));
129
+ }
130
+
131
+ function compareEnds(lower, upper, end, minStepPx) {
132
+ const a = lower[end];
133
+ const b = upper[end];
134
+ if (a === null || b === null) return null;
135
+ if (numbersEqual(a, b)) return { type: 'collapsed' };
136
+ if (a > b) return { type: 'inverted' };
137
+ if (b - a < minStepPx) return { difference: Math.round((b - a) * 1000) / 1000, type: 'too-close' };
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Every ladder of at least three evaluable steps, with the problems between
143
+ * adjacent steps. `declarations` is the token-chain map: token to the set of
144
+ * values it is declared with.
145
+ */
146
+ function resolveTokenLadders({ baseFontSize = 16, declarations, minStepPx = DEFAULT_MIN_STEP_PX }) {
147
+ const { evaluateToken } = createEvaluator(declarations, baseFontSize);
148
+ const ladders = new Map();
149
+ for (const token of declarations.keys()) {
150
+ const step = ladderStep(token);
151
+ if (!step) continue;
152
+ const range = evaluateToken(token, new Set());
153
+ if (!range) continue;
154
+ if (!ladders.has(step.key)) ladders.set(step.key, { prefix: step.prefix, steps: [] });
155
+ const ladder = ladders.get(step.key);
156
+ // An alias for a rank already on the ladder (s next to sm) is not a step.
157
+ if (ladder.steps.some((existing) => existing.rank === step.rank)) continue;
158
+ ladder.steps.push({ max: range.max, min: range.min, rank: step.rank, token });
159
+ }
160
+
161
+ const entries = [];
162
+ for (const ladder of ladders.values()) {
163
+ if (ladder.steps.length < MIN_LADDER_STEPS) continue;
164
+ const steps = ladder.steps.sort((a, b) => a.rank - b.rank);
165
+ const problems = [];
166
+ for (let index = 0; index < steps.length - 1; index += 1) {
167
+ const tokens = [steps[index].token, steps[index + 1].token];
168
+ const atMin = compareEnds(steps[index], steps[index + 1], 'min', minStepPx);
169
+ const atMax = compareEnds(steps[index], steps[index + 1], 'max', minStepPx);
170
+ // The same problem at both ends (always, for static steps) is one problem.
171
+ if (atMin && atMax && atMin.type === atMax.type && atMin.difference === atMax.difference) {
172
+ problems.push({ end: 'both', tokens, ...atMin });
173
+ continue;
174
+ }
175
+ if (atMin) problems.push({ end: 'min', tokens, ...atMin });
176
+ if (atMax) problems.push({ end: 'max', tokens, ...atMax });
177
+ }
178
+ entries.push({
179
+ prefix: ladder.prefix,
180
+ problems,
181
+ steps: steps.map(({ max, min, token }) => ({
182
+ max: max === null ? null : formatLength(max, 'px'),
183
+ min: min === null ? null : formatLength(min, 'px'),
184
+ token,
185
+ })),
186
+ });
187
+ }
188
+ entries.sort((a, b) => a.prefix.localeCompare(b.prefix));
189
+
190
+ const count = (type) => entries.reduce((sum, entry) => sum + entry.problems.filter((problem) => problem.type === type).length, 0);
191
+ return {
192
+ entries,
193
+ summary: {
194
+ collapsed: count('collapsed'),
195
+ inverted: count('inverted'),
196
+ ladders: entries.length,
197
+ minStepPx,
198
+ tooClose: count('too-close'),
199
+ },
200
+ };
201
+ }
202
+
203
+ /** The contract shape: counts for every ladder, entries only for ladders with a problem. */
204
+ function toLaddersContract(ladders) {
205
+ return {
206
+ entries: ladders.entries.filter((entry) => entry.problems.length > 0),
207
+ summary: ladders.summary,
208
+ };
209
+ }
210
+
211
+ module.exports = {
212
+ DEFAULT_MIN_STEP_PX,
213
+ ladderStep,
214
+ resolveTokenLadders,
215
+ toLaddersContract,
216
+ };
@@ -7,8 +7,8 @@
7
7
  * spacing tokens (scaleSources, .rhythmguardrc.json token sources, the
8
8
  * stylesheet's custom properties, a Tailwind config, then rhythmic-4).
9
9
  * No `extends`, no dependency beyond this plugin, and the shape is frozen for
10
- * the life of the 2.x line so a config that embeds it never changes behaviour
11
- * on a minor bump.
10
+ * the life of a major version so a config that embeds it never changes
11
+ * behaviour on a minor bump.
12
12
  */
13
13
  module.exports = {
14
14
  plugins: ['stylelint-plugin-rhythmguard'],
@@ -41,6 +41,30 @@ const PROPERTY_GROUP_PATTERNS = Object.freeze({
41
41
  ]),
42
42
  });
43
43
 
44
+ /** The property group a CSS property belongs to, or null. */
45
+ function propertyGroupOf(prop) {
46
+ const lower = String(prop || '').toLowerCase();
47
+ const found = Object.entries(PROPERTY_GROUP_PATTERNS).find(([, patterns]) => patterns.some((pattern) => pattern.test(lower)));
48
+ return found ? found[0] : null;
49
+ }
50
+
51
+ // Properties whose length is a different kind from the group that checks them.
52
+ // outline-offset is checked with the radius group (it always has been), but it
53
+ // is the gap between the border edge and the outline: a spacing length. A
54
+ // radius token there would tie every focus ring to the corner radius.
55
+ const PROPERTY_KIND_OVERRIDES = Object.freeze({
56
+ 'outline-offset': 'spacing',
57
+ });
58
+
59
+ /**
60
+ * The kind of length a property takes, for choosing tokens and a group's
61
+ * scale. The property group, except where PROPERTY_KIND_OVERRIDES says otherwise.
62
+ */
63
+ function propertyKindOf(prop) {
64
+ const lower = String(prop || '').toLowerCase();
65
+ return PROPERTY_KIND_OVERRIDES[lower] || propertyGroupOf(lower);
66
+ }
67
+
44
68
  const DEFAULT_PROPERTY_GROUPS = Object.freeze(['spacing']);
45
69
  const PROPERTY_GROUP_NAMES = Object.freeze(Object.keys(PROPERTY_GROUP_PATTERNS));
46
70
 
@@ -101,6 +125,8 @@ const SUPPORTED_SCALE_UNITS = new Set([
101
125
  const DEFAULT_MATH_FUNCTION_ARGUMENT_FILTER = Object.freeze({});
102
126
 
103
127
  module.exports = {
128
+ propertyGroupOf,
129
+ propertyKindOf,
104
130
  DEFAULT_IGNORE_KEYWORDS,
105
131
  DEFAULT_MATH_FUNCTION_ARGUMENT_FILTER,
106
132
  DEFAULT_PROPERTY_GROUPS,
@@ -11,6 +11,7 @@ const {
11
11
  PROPERTY_GROUP_PATTERNS,
12
12
  SPACING_PROPERTY_PATTERNS,
13
13
  SUPPORTED_SCALE_UNITS,
14
+ propertyKindOf,
14
15
  } = require('./css-vocabulary');
15
16
  const { parseLengthToken } = require('./length');
16
17
  const {
@@ -257,6 +258,42 @@ function isPropertyScaleMap(value) {
257
258
  });
258
259
  }
259
260
 
261
+ // Groups that take a length scale of their own; motion has use-motion-scale.
262
+ const SCALED_GROUPS = Object.freeze(['spacing', 'radius', 'size', 'typography']);
263
+
264
+ /** One group's scale: values, a preset name, "auto", or a { preset | scale | customScale } object. */
265
+ function isGroupScaleEntry(value) {
266
+ if (isAutoScaleLiteral(value)) {
267
+ return true;
268
+ }
269
+ if (typeof value === 'string') {
270
+ return Boolean(getScalePreset(value));
271
+ }
272
+ return isScaleOverrideEntry(value);
273
+ }
274
+
275
+ function isGroupScalesMap(value) {
276
+ return isPlainObject(value)
277
+ && Object.entries(value).every(([group, scale]) => SCALED_GROUPS.includes(group) && isGroupScaleEntry(scale));
278
+ }
279
+
280
+ /**
281
+ * `scales` normalised to { group: { auto, scale } }. A group set to "auto"
282
+ * has no scale until the rule infers one from that group's tokens.
283
+ */
284
+ function buildGroupScales(scales) {
285
+ if (!isGroupScalesMap(scales)) {
286
+ return {};
287
+ }
288
+ return Object.fromEntries(Object.entries(scales).map(([group, entry]) => {
289
+ if (isAutoScaleLiteral(entry)) {
290
+ return [group, { auto: true, scale: null }];
291
+ }
292
+ const scale = typeof entry === 'string' ? getScalePreset(entry) : resolveScaleOverride(entry);
293
+ return [group, { auto: false, scale: Array.isArray(scale) ? [...scale] : null }];
294
+ }));
295
+ }
296
+
260
297
  function buildPossibleOptionMap(schema) {
261
298
  return Object.fromEntries(
262
299
  Object.entries(schema).map(([optionName, descriptor]) => [
@@ -398,6 +435,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
398
435
  entryValidator: isScaleEntry,
399
436
  expectsArray: true,
400
437
  }),
438
+ checkMathBounds: Object.freeze({
439
+ entryValidator: isBoolean,
440
+ }),
401
441
  enforceInsideMathFunctions: Object.freeze({
402
442
  entryValidator: isBoolean,
403
443
  }),
@@ -460,6 +500,10 @@ const USE_SCALE_VALIDATION_SCHEMA = Object.freeze({
460
500
  entryValidator: isPropertyScaleMap,
461
501
  expectsObject: true,
462
502
  }),
503
+ scales: Object.freeze({
504
+ entryValidator: isGroupScalesMap,
505
+ expectsObject: true,
506
+ }),
463
507
  tokenFunctions: Object.freeze({
464
508
  entryValidator: isNonEmptyString,
465
509
  expectsArray: true,
@@ -487,6 +531,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
487
531
  entryValidator: isScaleEntry,
488
532
  expectsArray: true,
489
533
  }),
534
+ checkMathBounds: Object.freeze({
535
+ entryValidator: isBoolean,
536
+ }),
490
537
  enforceInsideMathFunctions: Object.freeze({
491
538
  entryValidator: isBoolean,
492
539
  }),
@@ -520,6 +567,10 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
520
567
  entryValidator: isPropertyScaleMap,
521
568
  expectsObject: true,
522
569
  }),
570
+ scales: Object.freeze({
571
+ entryValidator: isGroupScalesMap,
572
+ expectsObject: true,
573
+ }),
523
574
  scale: Object.freeze({
524
575
  allowLiterals: ['auto'],
525
576
  entryValidator: isScaleEntryOrAuto,
@@ -586,6 +637,7 @@ function buildScaleOptions(rawOptions) {
586
637
  options.baseFontSize > 0
587
638
  ? options.baseFontSize
588
639
  : 16,
640
+ checkMathBounds: options.checkMathBounds === true,
589
641
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
590
642
  fixToScale: options.fixToScale !== false,
591
643
  fixWith: options.fixWith === 'token' ? 'token' : 'value',
@@ -601,6 +653,7 @@ function buildScaleOptions(rawOptions) {
601
653
  properties: resolvePropertyPatterns(options),
602
654
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
603
655
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
656
+ groupScales: buildGroupScales(options.scales),
604
657
  scale: scaleSelection.scale,
605
658
  scaleAuto: isAutoScaleLiteral(options.scale),
606
659
  scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
@@ -634,6 +687,7 @@ function buildTokenOptions(rawOptions) {
634
687
  options.baseFontSize > 0
635
688
  ? options.baseFontSize
636
689
  : 16,
690
+ checkMathBounds: options.checkMathBounds === true,
637
691
  enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
638
692
  ignoreMathFunctionArguments: normalizeMathFunctionArgumentMap(options.ignoreMathFunctionArguments),
639
693
  ignoreValues: Array.isArray(options.ignoreValues)
@@ -647,6 +701,7 @@ function buildTokenOptions(rawOptions) {
647
701
  properties: resolvePropertyPatterns(options),
648
702
  propertyGroups: normalizePropertyGroups(options.propertyGroups),
649
703
  propertyScaleOverrides: buildPropertyScaleOverrides(options.propertyScales),
704
+ groupScales: buildGroupScales(options.scales),
650
705
  scale: scaleSelection.scale,
651
706
  scaleAuto: isAutoScaleLiteral(options.scale),
652
707
  scaleSources: Array.isArray(options.scaleSources) ? options.scaleSources : [],
@@ -688,8 +743,9 @@ function createPropertyScaleResolver(options) {
688
743
  if (cached) {
689
744
  return cached;
690
745
  }
691
- const selectedScale = resolvePropertyScale(prop, options);
746
+ const { group, scale: selectedScale } = resolvePropertyScaleEntry(prop, options);
692
747
  const state = {
748
+ group,
693
749
  scaleByUnit: normalizeScaleByUnit(selectedScale),
694
750
  scalePx: normalizeScale(selectedScale, options.baseFontSize),
695
751
  };
@@ -699,29 +755,41 @@ function createPropertyScaleResolver(options) {
699
755
  }
700
756
 
701
757
  function resolvePropertyScale(prop, options) {
702
- if (!Array.isArray(options.propertyScaleOverrides)) {
703
- return options.scale;
704
- }
758
+ return resolvePropertyScaleEntry(prop, options).scale;
759
+ }
705
760
 
706
- for (const override of options.propertyScaleOverrides) {
761
+ /**
762
+ * The scale a property is checked against, most specific first: a
763
+ * `propertyScales` entry for the property, then the `scales` entry for its
764
+ * group, then `scale`. `group` is set only when `scales` supplied it, so a
765
+ * message can say which scale it means.
766
+ */
767
+ function resolvePropertyScaleEntry(prop, options) {
768
+ for (const override of options.propertyScaleOverrides || []) {
707
769
  if (override.matcher instanceof RegExp) {
708
770
  if (override.matcher.global || override.matcher.sticky) {
709
771
  override.matcher.lastIndex = 0;
710
772
  }
711
773
 
712
774
  if (override.matcher.test(prop)) {
713
- return override.scale;
775
+ return { group: null, scale: override.scale };
714
776
  }
715
777
 
716
778
  continue;
717
779
  }
718
780
 
719
781
  if (String(override.matcher).toLowerCase() === prop.toLowerCase()) {
720
- return override.scale;
782
+ return { group: null, scale: override.scale };
721
783
  }
722
784
  }
723
785
 
724
- return options.scale;
786
+ const group = propertyKindOf(prop);
787
+ const groupScale = group && options.groupScales ? options.groupScales[group] : null;
788
+ if (groupScale && Array.isArray(groupScale.scale)) {
789
+ return { group, scale: groupScale.scale };
790
+ }
791
+
792
+ return { group: null, scale: options.scale };
725
793
  }
726
794
 
727
795
  module.exports = {
@@ -737,7 +805,9 @@ module.exports = {
737
805
  USE_SCALE_VALIDATION_SCHEMA,
738
806
  buildScaleOptions,
739
807
  buildTokenOptions,
808
+ SCALED_GROUPS,
740
809
  createPropertyScaleResolver,
810
+ isGroupScalesMap,
741
811
  isPlainObject,
742
812
  resolvePropertyScale,
743
813
  };
@@ -201,12 +201,16 @@ function scaleFromSources(sources, baseFontSize) {
201
201
  * `.rhythmguardrc.json` token sources and installed token packages. The same
202
202
  * places scale inference reads, in the same order.
203
203
  */
204
- function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenRegex }) {
204
+ function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenPattern = null, tokenRegex }) {
205
205
  const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
206
206
  const sources = [...scaleSources, ...rcTokenSources(process.cwd()), ...discoverTokenPackages(process.cwd())];
207
- const parsed = parseSourcesCached(sources, baseFontSize);
208
- if (parsed) {
209
- for (const [token, definition] of parsed.definitions) {
207
+ // A rule's own tokenPattern (`^--space-|^--radius-`) also applies to the
208
+ // files it reads, on top of the spacing names, unless a source sets its own.
209
+ const withPattern = tokenPattern
210
+ ? sources.map((source) => (typeof source === 'string' ? { path: source, tokenPattern } : { tokenPattern, ...source }))
211
+ : [];
212
+ for (const parsed of [parseSourcesCached(sources, baseFontSize), parseSourcesCached(withPattern, baseFontSize)]) {
213
+ for (const [token, definition] of parsed ? parsed.definitions : []) {
210
214
  if (!definitions.has(token)) {
211
215
  definitions.set(token, definition);
212
216
  }
@@ -518,19 +522,58 @@ function fallbackInference(rejected = null) {
518
522
  * an explicit scale pass through untouched.
519
523
  */
520
524
  function withResolvedScale(options, root) {
521
- if (!options.scaleAuto) {
522
- return options;
525
+ if (options.scaleAuto) {
526
+ const inference = resolveAutoScale({
527
+ baseFontSize: options.baseFontSize,
528
+ root,
529
+ scaleSources: options.scaleSources,
530
+ tailwindConfigPath: options.tailwindConfigPath,
531
+ tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
532
+ });
533
+ options.scale = inference.scale;
534
+ options.scaleInference = inference;
535
+ }
536
+ for (const [group, entry] of Object.entries(options.groupScales || {})) {
537
+ if (entry.auto) {
538
+ entry.scale = inferGroupScale(group, options, root);
539
+ }
540
+ }
541
+ return options;
542
+ }
543
+
544
+ // Token names each group's "auto" reads. Spacing is the project scale itself.
545
+ const GROUP_TOKEN_PATTERNS = Object.freeze({
546
+ radius: /^--radius-/,
547
+ size: /^--(?:size|width|height|container)-/,
548
+ typography: /^--(?:font-size|text|fs)-/,
549
+ });
550
+
551
+ /**
552
+ * A group's scale from its own tokens (`--font-size-*` and Tailwind's
553
+ * `--text-*` for typography, `--radius-*` for radius). Null when fewer than
554
+ * four values are found, and the group then uses `scale`. Fluid tokens
555
+ * (`clamp()`) have no single value and are not part of an inferred scale.
556
+ */
557
+ function inferGroupScale(group, options, root) {
558
+ if (group === 'spacing') {
559
+ return options.scaleAuto ? options.scale : resolveAutoScale({
560
+ baseFontSize: options.baseFontSize,
561
+ root,
562
+ scaleSources: options.scaleSources,
563
+ tailwindConfigPath: options.tailwindConfigPath,
564
+ tokenPattern: DEFAULT_AUTO_TOKEN_PATTERN,
565
+ }).scale;
523
566
  }
524
- const inference = resolveAutoScale({
567
+ const pattern = GROUP_TOKEN_PATTERNS[group];
568
+ const definitions = collectTokenDefinitions({
525
569
  baseFontSize: options.baseFontSize,
526
570
  root,
527
571
  scaleSources: options.scaleSources,
528
- tailwindConfigPath: options.tailwindConfigPath,
529
- tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
572
+ tokenPattern: pattern.source,
573
+ tokenRegex: pattern,
530
574
  });
531
- options.scale = inference.scale;
532
- options.scaleInference = inference;
533
- return options;
575
+ const own = new Map([...definitions].filter(([token]) => pattern.test(token)));
576
+ return own.size > 0 ? scaleFromDefinitions(own, options.baseFontSize) : null;
534
577
  }
535
578
 
536
579
  function autoScaleFallbackNote(inference) {
@@ -1,7 +1,39 @@
1
1
  'use strict';
2
2
 
3
+ const { propertyKindOf } = require('./css-vocabulary');
3
4
  const { fixedLengthValue, numbersEqual, toPx } = require('./length');
4
- const { parseTokenValueLength } = require('./token-sources');
5
+ const { createTokenKindMatcher, parseTokenValueLength } = require('./token-sources');
6
+
7
+ // Dedicated kinds first: a name like --radius-space-1 is a radius token.
8
+ const TOKEN_KINDS = ['radius', 'typography', 'motion', 'size', 'spacing'].map((kind) => [kind, createTokenKindMatcher(kind)]);
9
+
10
+ // Which token kinds may stand in for a property of each group. Spacing and size
11
+ // share tokens in many systems (Open Props --size-*, mittwald --size-px--*);
12
+ // radius, type and motion tokens serve only their own properties.
13
+ const ACCEPTED_KINDS = Object.freeze({
14
+ motion: ['motion'],
15
+ radius: ['radius'],
16
+ size: ['size', 'spacing'],
17
+ spacing: ['spacing', 'size'],
18
+ typography: ['typography'],
19
+ });
20
+
21
+ /** The kind a token's name declares, or null when the name says nothing. */
22
+ function tokenKindOf(token) {
23
+ const found = TOKEN_KINDS.find(([, matches]) => matches(token));
24
+ return found ? found[0] : null;
25
+ }
26
+
27
+ /**
28
+ * Whether a token may be offered for a property: a padding never gets a radius
29
+ * token, even when both hold 16px and the token pattern admits both. A token
30
+ * whose name declares no kind, or a property outside every group, is allowed.
31
+ */
32
+ function tokenFitsProperty(token, prop) {
33
+ const group = prop ? propertyKindOf(prop) : null;
34
+ const kind = group ? tokenKindOf(String(token).replace(/^var\(\s*|\s*\)$/g, '')) : null;
35
+ return !group || !kind || ACCEPTED_KINDS[group].includes(kind);
36
+ }
5
37
 
6
38
  /**
7
39
  * Which token a fix may write for a length. Strict on purpose: the token must
@@ -19,24 +51,37 @@ function tokenIndexFromDefinitions(definitions, baseFontSize = 16) {
19
51
  const unit = parsed.unit || 'px';
20
52
  const px = toPx(Math.abs(parsed.number), unit, baseFontSize);
21
53
  if (px === null || !Number.isFinite(px) || px === 0) continue;
22
- index.push({ px, raw: String(raw).trim(), token: definition.token, unit });
54
+ const file = definition.files ? [...definition.files][0] : null;
55
+ index.push({ file: file && file !== 'stylesheet' ? file : null, px, raw: String(raw).trim(), token: definition.token, unit });
23
56
  }
24
57
  }
25
58
  return index;
26
59
  }
27
60
 
28
- function tokenForLength(index, px, unit) {
61
+ /**
62
+ * Every custom property that holds a length, for one property. The shared
63
+ * resolver behind use-scale's `fixWith: "token"` and prefer-token, so the two
64
+ * rules always know the same tokens. Same unit first; with `anyUnit`, a token
65
+ * in another unit that holds the same px value counts when none matches the
66
+ * unit. Sorted, so messages are stable.
67
+ */
68
+ function tokensForLength(index, px, unit, { anyUnit = false, prop = null } = {}) {
29
69
  const wanted = unit || 'px';
30
- const candidates = new Set();
70
+ const sameUnit = new Set();
71
+ const otherUnit = new Set();
31
72
  for (const entry of index) {
32
- if (entry.token.startsWith('--') && entry.unit === wanted && numbersEqual(entry.px, Math.abs(px))) {
33
- candidates.add(entry.token);
73
+ if (!entry.token.startsWith('--') || !numbersEqual(entry.px, Math.abs(px)) || !tokenFitsProperty(entry.token, prop)) {
74
+ continue;
34
75
  }
76
+ (entry.unit === wanted ? sameUnit : otherUnit).add(entry.token);
35
77
  }
36
- if (candidates.size !== 1) {
37
- return null;
38
- }
39
- return `var(${[...candidates][0]})`;
78
+ const found = sameUnit.size > 0 || !anyUnit ? sameUnit : otherUnit;
79
+ return [...found].sort((a, b) => a.localeCompare(b, 'en', { numeric: true }));
80
+ }
81
+
82
+ function tokenForLength(index, px, unit, prop = null) {
83
+ const candidates = tokensForLength(index, px, unit, { prop });
84
+ return candidates.length === 1 ? `var(${candidates[0]})` : null;
40
85
  }
41
86
 
42
87
  /** A negative token reference that is still valid where it is written. */
@@ -54,9 +99,9 @@ function negateReplacement(replacement) {
54
99
  * The replacement text a length rule writes: the matching token when the
55
100
  * options ask for one and exactly one qualifies, otherwise the literal.
56
101
  */
57
- function replacementFor(parsedLength, nearestPx, options) {
102
+ function replacementFor(parsedLength, nearestPx, options, prop = null) {
58
103
  if (options.fixWith === 'token' && options.tokenIndex) {
59
- const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px');
104
+ const token = tokenForLength(options.tokenIndex, nearestPx, parsedLength.unit || 'px', prop);
60
105
  if (token) {
61
106
  return parsedLength.number < 0 ? negateReplacement(token) : token;
62
107
  }
@@ -76,7 +121,10 @@ function tokenHoldsNote(replacement, formattedNearest) {
76
121
  module.exports = {
77
122
  negateReplacement,
78
123
  replacementFor,
124
+ tokenFitsProperty,
79
125
  tokenHoldsNote,
80
126
  tokenForLength,
81
127
  tokenIndexFromDefinitions,
128
+ tokenKindOf,
129
+ tokensForLength,
82
130
  };
@@ -86,6 +86,13 @@
86
86
  "dist/nhsuk/core/settings/_spacing.scss"
87
87
  ],
88
88
  "note": "$nhsuk-spacing-points map"
89
+ },
90
+ {
91
+ "name": "@mozilla-protocol/tokens",
92
+ "files": [
93
+ "dist/units/units.custom-properties.css"
94
+ ],
95
+ "note": "--spacing-xs..2xl in px; Protocol's Sass $spacing-* come from the same package"
89
96
  }
90
97
  ]
91
98
  }