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.
- package/CHANGELOG.md +46 -0
- package/CONTRIBUTING.md +11 -6
- package/README.md +16 -9
- package/package.json +7 -7
- package/src/audit/args.js +2 -0
- package/src/audit/config.js +24 -1
- package/src/audit/contract.js +3 -1
- package/src/audit/render-markdown.js +35 -0
- package/src/audit/render-text.js +6 -0
- package/src/audit/report.js +15 -5
- package/src/audit/scan/files.js +2 -2
- package/src/audit/scan/stylesheets.js +14 -2
- package/src/audit/scan/templates.js +45 -11
- package/src/audit/shared.js +3 -0
- package/src/audit/token-ladders.js +216 -0
- package/src/configs/embed.js +2 -2
- package/src/core/css-vocabulary.js +26 -0
- package/src/core/options.js +78 -8
- package/src/core/scale-inference.js +55 -12
- package/src/core/token-index.js +60 -12
- package/src/core/token-packages.json +7 -0
- package/src/core/token-sources.js +2 -2
- package/src/core/value-nodes.js +51 -0
- package/src/rules/no-offscale-transform/index.js +9 -3
- package/src/rules/prefer-token/index.js +93 -12
- package/src/rules/use-scale/index.js +41 -8
- package/types/audit.d.ts +6 -0
- package/types/shared.d.ts +8 -0
|
@@ -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
|
+
};
|
package/src/configs/embed.js
CHANGED
|
@@ -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
|
|
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,
|
package/src/core/options.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
703
|
-
|
|
704
|
-
}
|
|
758
|
+
return resolvePropertyScaleEntry(prop, options).scale;
|
|
759
|
+
}
|
|
705
760
|
|
|
706
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 (
|
|
522
|
-
|
|
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
|
|
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
|
-
|
|
529
|
-
|
|
572
|
+
tokenPattern: pattern.source,
|
|
573
|
+
tokenRegex: pattern,
|
|
530
574
|
});
|
|
531
|
-
|
|
532
|
-
options.
|
|
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) {
|
package/src/core/token-index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
+
const sameUnit = new Set();
|
|
71
|
+
const otherUnit = new Set();
|
|
31
72
|
for (const entry of index) {
|
|
32
|
-
if (entry.token.startsWith('--')
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
}
|