stylelint-plugin-rhythmguard 3.2.0 → 3.4.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 (38) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/CONTRIBUTING.md +19 -3
  3. package/README.md +2 -1
  4. package/package.json +9 -8
  5. package/src/audit/args.js +103 -307
  6. package/src/audit/config.js +1 -1
  7. package/src/audit/contract.js +6 -28
  8. package/src/audit/index.js +4 -7
  9. package/src/audit/render-markdown.js +3 -0
  10. package/src/audit/render-text.js +2 -1
  11. package/src/audit/report.js +26 -19
  12. package/src/audit/scan/files.js +262 -0
  13. package/src/audit/scan/stylesheets.js +275 -0
  14. package/src/audit/scan/templates.js +175 -0
  15. package/src/cli/doctor.js +1 -1
  16. package/src/cli/quickstart.js +5 -2
  17. package/src/{utils → core}/length.js +24 -1
  18. package/src/{utils → core}/options.js +32 -108
  19. package/src/core/scale-inference.js +560 -0
  20. package/src/{utils → core}/token-packages.json +7 -0
  21. package/src/{utils → core}/token-sources.js +155 -17
  22. package/src/{utils/value-utils.js → core/value-nodes.js} +1 -17
  23. package/src/eslint/rules/tailwind-class-use-motion-scale.js +2 -2
  24. package/src/eslint/rules/tailwind-class-use-scale.js +2 -2
  25. package/src/rules/no-offscale-transform/index.js +22 -91
  26. package/src/rules/prefer-token/index.js +15 -73
  27. package/src/rules/report.js +75 -0
  28. package/src/rules/use-motion-scale/index.js +25 -42
  29. package/src/rules/use-scale/index.js +21 -94
  30. package/src/rules/validate.js +132 -0
  31. package/types/audit.d.ts +11 -0
  32. package/src/audit/scan.js +0 -676
  33. package/src/utils/scale-inference.js +0 -351
  34. /package/src/{utils/constants.js → core/css-vocabulary.js} +0 -0
  35. /package/src/{utils → core}/tailwind-class-analysis.js +0 -0
  36. /package/src/{utils → core}/tailwind-motion-analysis.js +0 -0
  37. /package/src/{utils → core}/time.js +0 -0
  38. /package/src/{utils → core}/token-map.js +0 -0
@@ -0,0 +1,275 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Findings from stylesheets: run the rules through Stylelint's API, turn each
5
+ * warning into a finding, and recover the CSS property at the warning position
6
+ * from the source, since Stylelint reports positions rather than nodes.
7
+ */
8
+ const fs = require('node:fs');
9
+ const {
10
+ DEFAULT_AUDIT_TOKEN_PATTERN,
11
+ formatPath,
12
+ pluginPath,
13
+ } = require('../shared');
14
+ const { isScssFile } = require('./files');
15
+
16
+ let scssSyntaxCache;
17
+
18
+ /**
19
+ * postcss-scss is an optional peer. Resolve it from the audited project first,
20
+ * then from this package. Returns null when unavailable; SCSS files are then
21
+ * counted as skipped instead of failing the audit.
22
+ */
23
+ function resolveScssSyntax() {
24
+ if (scssSyntaxCache !== undefined) {
25
+ return scssSyntaxCache;
26
+ }
27
+ try {
28
+ scssSyntaxCache = require.resolve('postcss-scss', { paths: [process.cwd(), __dirname] });
29
+ } catch {
30
+ scssSyntaxCache = null;
31
+ }
32
+ return scssSyntaxCache;
33
+ }
34
+
35
+ async function runStylelintAudit(cssFiles, options) {
36
+ if (cssFiles.length === 0) {
37
+ const empty = [];
38
+ empty.scssFiles = 0;
39
+ empty.scssSkipped = 0;
40
+ return empty;
41
+ }
42
+
43
+ const { default: stylelint } = await import('stylelint');
44
+ const rules = {
45
+ 'rhythmguard/use-scale': [
46
+ true,
47
+ {
48
+ baseFontSize: options.baseFontSize,
49
+ scale: options.scale,
50
+ severity: 'warning',
51
+ },
52
+ ],
53
+ 'rhythmguard/prefer-token': [
54
+ true,
55
+ {
56
+ baseFontSize: options.baseFontSize,
57
+ scale: options.scale,
58
+ severity: 'warning',
59
+ tokenMapFromCssCustomProperties: true,
60
+ tokenPattern: DEFAULT_AUDIT_TOKEN_PATTERN,
61
+ },
62
+ ],
63
+ };
64
+
65
+ if (options.includeMotion) {
66
+ rules['rhythmguard/use-motion-scale'] = [
67
+ true,
68
+ {
69
+ severity: 'warning',
70
+ },
71
+ ];
72
+ }
73
+
74
+ const plainFiles = cssFiles.filter((file) => !isScssFile(file));
75
+ const scssFiles = cssFiles.filter(isScssFile);
76
+ const results = [];
77
+
78
+ if (plainFiles.length > 0) {
79
+ const result = await stylelint.lint({
80
+ files: plainFiles,
81
+ config: {
82
+ plugins: [pluginPath],
83
+ rules,
84
+ },
85
+ });
86
+ results.push(...(result.results || []));
87
+ }
88
+
89
+ let scssSkipped = 0;
90
+ if (scssFiles.length > 0) {
91
+ const scssSyntax = resolveScssSyntax();
92
+ if (scssSyntax) {
93
+ const result = await stylelint.lint({
94
+ files: scssFiles,
95
+ config: {
96
+ customSyntax: scssSyntax,
97
+ plugins: [pluginPath],
98
+ rules,
99
+ },
100
+ });
101
+ results.push(...(result.results || []));
102
+ } else {
103
+ scssSkipped = scssFiles.length;
104
+ }
105
+ }
106
+
107
+ results.scssFiles = scssFiles.length;
108
+ results.scssSkipped = scssSkipped;
109
+ return results;
110
+ }
111
+
112
+ function collectCssFindings(fileResults) {
113
+ const findings = [];
114
+ const sources = new Map();
115
+
116
+ for (const fileResult of fileResults) {
117
+ for (const warning of fileResult.warnings || []) {
118
+ const text = warning.text || '';
119
+ const source = readSourceOnce(sources, fileResult.source);
120
+ const offScaleMatch = text.match(
121
+ /Unexpected (?:off-scale value|transform translation value) "([^"]+)"/,
122
+ );
123
+ const tokenMatch = text.match(
124
+ /Unexpected raw scale value "([^"]+)"/,
125
+ );
126
+ const motionDurationMatch = text.match(
127
+ /Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
128
+ );
129
+ const motionEasingMatch = text.match(
130
+ /Unexpected raw motion easing "([^"]+)"/,
131
+ );
132
+
133
+ findings.push({
134
+ column: warning.column || 1,
135
+ file: formatPath(fileResult.source),
136
+ line: warning.line || 1,
137
+ property: source === null
138
+ ? null
139
+ : findDeclarationProperty(source, warning.line || 1, warning.column || 1),
140
+ rule: warning.rule || 'rhythmguard',
141
+ text,
142
+ type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
143
+ value: getCssFindingValue({
144
+ motionDurationMatch,
145
+ motionEasingMatch,
146
+ offScaleMatch,
147
+ tokenMatch,
148
+ }),
149
+ });
150
+ }
151
+ }
152
+
153
+ return findings;
154
+ }
155
+
156
+ function readSourceOnce(cache, filePath) {
157
+ if (!filePath) {
158
+ return null;
159
+ }
160
+ if (!cache.has(filePath)) {
161
+ try {
162
+ cache.set(filePath, fs.readFileSync(filePath, 'utf8'));
163
+ } catch {
164
+ cache.set(filePath, null);
165
+ }
166
+ }
167
+ return cache.get(filePath);
168
+ }
169
+
170
+ const DECLARATION_BOUNDARY = new Set([';', '{', '}']);
171
+
172
+ const DECLARATION_HEAD_PATTERN = /^\s*(?:(?:\/\*[\s\S]*?\*\/|\/\/[^\n]*)\s*)*(--[\w-]+|[a-zA-Z][\w-]*)\s*:/;
173
+
174
+ /**
175
+ * Stylelint warnings carry a position but not the declaration node. Recover the
176
+ * property by walking from the warning position back to the previous declaration
177
+ * boundary and reading the `property:` head, skipping block and Sass line comments.
178
+ * Sass interpolation is blanked first so `#{...}` braces do not act as boundaries. Returns null when the position is not
179
+ * inside a declaration, for example inside an at-rule.
180
+ */
181
+
182
+ function findDeclarationProperty(source, line, column) {
183
+ const offset = positionToOffset(source, line, column);
184
+ if (offset === null) {
185
+ return null;
186
+ }
187
+
188
+ const text = source.replace(/#\{[^}]*\}/g, (match) => ' '.repeat(match.length));
189
+ let start = offset;
190
+ while (start > 0 && !DECLARATION_BOUNDARY.has(text[start - 1])) {
191
+ start -= 1;
192
+ }
193
+ const backward = text.slice(start, offset).match(DECLARATION_HEAD_PATTERN);
194
+ if (backward) {
195
+ return backward[1];
196
+ }
197
+
198
+ let end = offset;
199
+ while (end < text.length && !DECLARATION_BOUNDARY.has(text[end])) {
200
+ end += 1;
201
+ }
202
+ const forward = text.slice(offset, end).match(DECLARATION_HEAD_PATTERN);
203
+ return forward ? forward[1] : null;
204
+ }
205
+
206
+ function positionToOffset(source, line, column) {
207
+ if (!Number.isInteger(line) || line < 1) {
208
+ return null;
209
+ }
210
+ let offset = 0;
211
+ let currentLine = 1;
212
+ while (currentLine < line) {
213
+ const newline = source.indexOf('\n', offset);
214
+ if (newline === -1) {
215
+ return null;
216
+ }
217
+ offset = newline + 1;
218
+ currentLine += 1;
219
+ }
220
+ const lineEnd = source.indexOf('\n', offset);
221
+ const lineLength = (lineEnd === -1 ? source.length : lineEnd) - offset;
222
+ return offset + Math.min(Math.max((column || 1) - 1, 0), lineLength);
223
+ }
224
+
225
+ function getCssFindingValue({
226
+ motionDurationMatch,
227
+ motionEasingMatch,
228
+ offScaleMatch,
229
+ tokenMatch,
230
+ }) {
231
+ if (tokenMatch) {
232
+ return tokenMatch[1];
233
+ }
234
+
235
+ if (offScaleMatch) {
236
+ return offScaleMatch[1];
237
+ }
238
+
239
+ if (motionDurationMatch) {
240
+ return motionDurationMatch[1];
241
+ }
242
+
243
+ if (motionEasingMatch) {
244
+ return motionEasingMatch[1];
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
251
+ if (motionDurationMatch) {
252
+ return 'motion-duration';
253
+ }
254
+
255
+ if (motionEasingMatch) {
256
+ return 'motion-easing';
257
+ }
258
+
259
+ if (tokenMatch) {
260
+ return 'token-opportunity';
261
+ }
262
+
263
+ return 'off-scale';
264
+ }
265
+
266
+ module.exports = {
267
+ collectCssFindings,
268
+ findDeclarationProperty,
269
+ getCssFindingType,
270
+ getCssFindingValue,
271
+ positionToOffset,
272
+ readSourceOnce,
273
+ resolveScssSyntax,
274
+ runStylelintAudit,
275
+ };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Findings from template files: Tailwind class strings inside string literals,
5
+ * for spacing and (opt-in) motion, with line and column recovered from offsets.
6
+ */
7
+ const fs = require('node:fs');
8
+ const { formatLength } = require('../../core/length');
9
+ const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
10
+ const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
11
+ const { formatTime } = require('../../core/time');
12
+ const {
13
+ formatPath,
14
+ } = require('../shared');
15
+
16
+ function collectTailwindFindings(templateFiles, options) {
17
+ const analyzer = createTailwindClassAnalyzer(options);
18
+ const findings = [];
19
+
20
+ for (const filePath of templateFiles) {
21
+ let source = '';
22
+ try {
23
+ source = fs.readFileSync(filePath, 'utf8');
24
+ } catch {
25
+ continue;
26
+ }
27
+
28
+ const lineStarts = getLineStarts(source);
29
+
30
+ for (const literal of findStringLiterals(source)) {
31
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
32
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
33
+ findings.push({
34
+ column: position.column,
35
+ file: formatPath(filePath),
36
+ fixedToken: analysis.fixedToken,
37
+ line: position.line,
38
+ nearest: analysis.nearest
39
+ ? {
40
+ lower: formatLength(analysis.nearest.lower, 'px'),
41
+ upper: formatLength(analysis.nearest.upper, 'px'),
42
+ }
43
+ : null,
44
+ rawValue: analysis.rawValue,
45
+ rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
46
+ text: analysis.reason === 'negative'
47
+ ? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
48
+ : `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values.`,
49
+ token: segment.token,
50
+ type: 'tailwind-arbitrary-spacing',
51
+ utility: analysis.utility,
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ return findings;
58
+ }
59
+
60
+ function collectTailwindMotionFindings(templateFiles, options) {
61
+ if (!options.includeMotion) {
62
+ return [];
63
+ }
64
+
65
+ const analyzer = createTailwindMotionAnalyzer(options);
66
+ const findings = [];
67
+
68
+ for (const filePath of templateFiles) {
69
+ let source = '';
70
+ try {
71
+ source = fs.readFileSync(filePath, 'utf8');
72
+ } catch {
73
+ continue;
74
+ }
75
+
76
+ const lineStarts = getLineStarts(source);
77
+
78
+ for (const literal of findStringLiterals(source)) {
79
+ for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
80
+ const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
81
+ findings.push({
82
+ column: position.column,
83
+ file: formatPath(filePath),
84
+ fixedToken: analysis.fixedToken,
85
+ line: position.line,
86
+ nearest: analysis.nearest
87
+ ? {
88
+ lower: formatTime(analysis.nearest.lower, 'ms'),
89
+ upper: formatTime(analysis.nearest.upper, 'ms'),
90
+ }
91
+ : null,
92
+ rawValue: analysis.rawValue,
93
+ rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
94
+ text: buildTailwindMotionFindingText(segment.token, analysis),
95
+ token: segment.token,
96
+ type: analysis.reason === 'easing'
97
+ ? 'tailwind-motion-easing'
98
+ : 'tailwind-motion-duration',
99
+ utility: analysis.utility,
100
+ });
101
+ }
102
+ }
103
+ }
104
+
105
+ return findings;
106
+ }
107
+
108
+ function buildTailwindMotionFindingText(token, analysis) {
109
+ if (analysis.reason === 'easing') {
110
+ return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
111
+ }
112
+
113
+ if (analysis.reason === 'negative') {
114
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
115
+ }
116
+
117
+ return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
118
+ }
119
+
120
+ function findStringLiterals(source) {
121
+ const literals = [];
122
+ const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
123
+ let match;
124
+
125
+ while ((match = literalPattern.exec(source)) !== null) {
126
+ literals.push({
127
+ quote: match[1],
128
+ value: match[2],
129
+ valueStart: match.index + 1,
130
+ });
131
+ }
132
+
133
+ return literals;
134
+ }
135
+
136
+ function getLineStarts(source) {
137
+ const starts = [0];
138
+
139
+ for (let index = 0; index < source.length; index++) {
140
+ if (source[index] === '\n') {
141
+ starts.push(index + 1);
142
+ }
143
+ }
144
+
145
+ return starts;
146
+ }
147
+
148
+ function offsetToLineColumn(lineStarts, offset) {
149
+ let low = 0;
150
+ let high = lineStarts.length - 1;
151
+
152
+ while (low <= high) {
153
+ const mid = Math.floor((low + high) / 2);
154
+ if (lineStarts[mid] <= offset) {
155
+ low = mid + 1;
156
+ } else {
157
+ high = mid - 1;
158
+ }
159
+ }
160
+
161
+ const lineIndex = Math.max(0, high);
162
+ return {
163
+ column: offset - lineStarts[lineIndex] + 1,
164
+ line: lineIndex + 1,
165
+ };
166
+ }
167
+
168
+ module.exports = {
169
+ buildTailwindMotionFindingText,
170
+ collectTailwindFindings,
171
+ collectTailwindMotionFindings,
172
+ findStringLiterals,
173
+ getLineStarts,
174
+ offsetToLineColumn,
175
+ };
package/src/cli/doctor.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const fs = require('node:fs');
4
4
  const path = require('node:path');
5
- const { normalizeTokenSourceFormat } = require('../utils/token-sources');
5
+ const { normalizeTokenSourceFormat } = require('../core/token-sources');
6
6
 
7
7
  const cwd = process.cwd();
8
8
  let issues = 0;
@@ -14,7 +14,7 @@ const path = require('node:path');
14
14
 
15
15
  const { createAuditReport } = require('../audit/report');
16
16
  const { detect } = require('./init');
17
- const { discoverTokenPackages } = require('../utils/scale-inference');
17
+ const { discoverTokenPackages } = require('../core/scale-inference');
18
18
 
19
19
  const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
20
20
  const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
@@ -160,7 +160,10 @@ async function run() {
160
160
  out.push(' Scale');
161
161
  out.push(` Scale ${scale.values.join(', ')}`);
162
162
  out.push(` Source ${describeScale(scale)}`);
163
- if (scale.source === 'fallback') {
163
+ if (scale.source === 'fallback' && scale.rejected) {
164
+ out.push(` Note Spacing tokens were found but do not form a scale (${scale.rejected.reasons.join(', ')}); the rhythmic-4 preset is being used.`);
165
+ out.push(' Point scaleSources at your real token file, or tighten tokenPattern.');
166
+ } else if (scale.source === 'fallback') {
164
167
  out.push(' Note No spacing tokens found; the rhythmic-4 preset is being used.');
165
168
  out.push(' Add scaleSources or define --space-*/--spacing-* custom properties to lint against your own scale.');
166
169
  }
@@ -69,8 +69,10 @@ function formatNumber(value) {
69
69
  return '0';
70
70
  }
71
71
 
72
+ // String() already prints the shortest form (1.5, not 1.50). The old
73
+ // trailing-zero strip also turned 30 into 3 and 100 into 1.
72
74
  const rounded = Math.round(value * 10000) / 10000;
73
- return String(rounded).replace(/\.?0+$/, '');
75
+ return String(rounded);
74
76
  }
75
77
 
76
78
  function formatLength(number, unit) {
@@ -180,7 +182,28 @@ function nearestScaleValues(target, scale) {
180
182
  };
181
183
  }
182
184
 
185
+ /**
186
+ * The replacement text for an off-scale length, or null when the value's unit
187
+ * is not one the rule may rewrite. Keeps the sign, and with the `exact` unit
188
+ * strategy keeps the unit as written instead of converting through px.
189
+ */
190
+ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy, units }) {
191
+ const unit = parsedLength.unit || 'px';
192
+ if (unit === '%' || !units.includes(unit)) {
193
+ return null;
194
+ }
195
+
196
+ const signedNearest = parsedLength.number < 0 ? -Math.abs(nearestPx) : nearestPx;
197
+ if (unitStrategy === 'exact') {
198
+ return formatLength(signedNearest, unit);
199
+ }
200
+
201
+ const converted = fromPx(signedNearest, unit, baseFontSize);
202
+ return converted === null ? null : formatLength(converted, unit);
203
+ }
204
+
183
205
  module.exports = {
206
+ fixedLengthValue,
184
207
  formatLength,
185
208
  fromPx,
186
209
  isHairlineLength,
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
- const stylelint = require('stylelint');
3
+ const { normalizeScale, normalizeScaleByUnit } = require('./length');
4
+
4
5
  const { all: knownCssProperties = [] } = require('known-css-properties');
5
6
  const {
6
7
  DEFAULT_IGNORE_KEYWORDS,
@@ -10,7 +11,7 @@ const {
10
11
  PROPERTY_GROUP_PATTERNS,
11
12
  SPACING_PROPERTY_PATTERNS,
12
13
  SUPPORTED_SCALE_UNITS,
13
- } = require('./constants');
14
+ } = require('./css-vocabulary');
14
15
  const { parseLengthToken } = require('./length');
15
16
  const {
16
17
  getScalePreset,
@@ -236,57 +237,6 @@ function isPropertyScaleMap(value) {
236
237
  });
237
238
  }
238
239
 
239
- function validateSecondaryOptionShapes(result, ruleName, secondaryOptions, schema) {
240
- if (secondaryOptions === undefined || secondaryOptions === null) {
241
- return true;
242
- }
243
-
244
- if (!isPlainObject(secondaryOptions)) {
245
- return true;
246
- }
247
-
248
- let valid = true;
249
-
250
- for (const [optionName, descriptor] of Object.entries(schema)) {
251
- const optionValue = secondaryOptions[optionName];
252
- if (optionValue === undefined) {
253
- continue;
254
- }
255
-
256
- const literalAllowed = Array.isArray(descriptor.allowLiterals)
257
- && descriptor.allowLiterals.includes(optionValue);
258
-
259
- if (descriptor.expectsArray && !Array.isArray(optionValue) && !literalAllowed) {
260
- valid = false;
261
- result.warn(
262
- `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
263
- { stylelintType: 'invalidOption' },
264
- );
265
- result.stylelint.stylelintError = true;
266
- continue;
267
- }
268
-
269
- if (descriptor.expectsObject && !isPlainObject(optionValue)) {
270
- valid = false;
271
- result.warn(
272
- `Invalid value ${stringifyOptionValue(optionValue)} for option "${optionName}" of rule "${ruleName}"`,
273
- { stylelintType: 'invalidOption' },
274
- );
275
- result.stylelint.stylelintError = true;
276
- }
277
- }
278
-
279
- return valid;
280
- }
281
-
282
- function stringifyOptionValue(value) {
283
- if (typeof value === 'string') {
284
- return `"${value}"`;
285
- }
286
-
287
- return `"${JSON.stringify(value)}"`;
288
- }
289
-
290
240
  function buildPossibleOptionMap(schema) {
291
241
  return Object.fromEntries(
292
242
  Object.entries(schema).map(([optionName, descriptor]) => [
@@ -296,28 +246,6 @@ function buildPossibleOptionMap(schema) {
296
246
  );
297
247
  }
298
248
 
299
- function validateSecondaryOptions({
300
- result,
301
- ruleName,
302
- secondaryOptions,
303
- schema,
304
- possibleOptionMap,
305
- }) {
306
- const validOptions = stylelint.utils.validateOptions(result, ruleName, {
307
- actual: secondaryOptions,
308
- optional: true,
309
- possible: possibleOptionMap,
310
- });
311
- const validShapes = validateSecondaryOptionShapes(
312
- result,
313
- ruleName,
314
- secondaryOptions,
315
- schema,
316
- );
317
-
318
- return validOptions && validShapes;
319
- }
320
-
321
249
  function normalizePropertyGroups(rawGroups) {
322
250
  const source = Array.isArray(rawGroups) && rawGroups.length > 0
323
251
  ? rawGroups
@@ -713,6 +641,27 @@ function buildTokenOptions(rawOptions) {
713
641
  };
714
642
  }
715
643
 
644
+ /**
645
+ * Per-property scale lookup with a cache, because a stylesheet repeats the
646
+ * same few properties thousands of times and normalising a scale is not free.
647
+ */
648
+ function createPropertyScaleResolver(options) {
649
+ const cache = new Map();
650
+ return (prop) => {
651
+ const cached = cache.get(prop);
652
+ if (cached) {
653
+ return cached;
654
+ }
655
+ const selectedScale = resolvePropertyScale(prop, options);
656
+ const state = {
657
+ scaleByUnit: normalizeScaleByUnit(selectedScale),
658
+ scalePx: normalizeScale(selectedScale, options.baseFontSize),
659
+ };
660
+ cache.set(prop, state);
661
+ return state;
662
+ };
663
+ }
664
+
716
665
  function resolvePropertyScale(prop, options) {
717
666
  if (!Array.isArray(options.propertyScaleOverrides)) {
718
667
  return options.scale;
@@ -739,41 +688,16 @@ function resolvePropertyScale(prop, options) {
739
688
  return options.scale;
740
689
  }
741
690
 
742
- function validateUseScaleSecondaryOptions(result, ruleName, secondaryOptions) {
743
- return validateSecondaryOptions({
744
- result,
745
- ruleName,
746
- secondaryOptions,
747
- schema: USE_SCALE_VALIDATION_SCHEMA,
748
- possibleOptionMap: USE_SCALE_POSSIBLE_OPTIONS,
749
- });
750
- }
751
-
752
- function validateNoOffscaleTransformSecondaryOptions(result, ruleName, secondaryOptions) {
753
- return validateSecondaryOptions({
754
- result,
755
- ruleName,
756
- secondaryOptions,
757
- schema: NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
758
- possibleOptionMap: NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
759
- });
760
- }
761
-
762
- function validatePreferTokenSecondaryOptions(result, ruleName, secondaryOptions) {
763
- return validateSecondaryOptions({
764
- result,
765
- ruleName,
766
- secondaryOptions,
767
- schema: PREFER_TOKEN_VALIDATION_SCHEMA,
768
- possibleOptionMap: PREFER_TOKEN_POSSIBLE_OPTIONS,
769
- });
770
- }
771
-
772
691
  module.exports = {
692
+ NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
693
+ NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
694
+ PREFER_TOKEN_POSSIBLE_OPTIONS,
695
+ PREFER_TOKEN_VALIDATION_SCHEMA,
696
+ USE_SCALE_POSSIBLE_OPTIONS,
697
+ USE_SCALE_VALIDATION_SCHEMA,
773
698
  buildScaleOptions,
774
699
  buildTokenOptions,
700
+ createPropertyScaleResolver,
701
+ isPlainObject,
775
702
  resolvePropertyScale,
776
- validateNoOffscaleTransformSecondaryOptions,
777
- validatePreferTokenSecondaryOptions,
778
- validateUseScaleSecondaryOptions,
779
703
  };