stylelint-plugin-rhythmguard 0.1.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,246 @@
1
+ 'use strict';
2
+
3
+ const stylelint = require('stylelint');
4
+ const valueParser = require('postcss-value-parser');
5
+ const {
6
+ formatLength,
7
+ fromPx,
8
+ nearestScaleValues,
9
+ normalizeScale,
10
+ numbersEqual,
11
+ parseLengthToken,
12
+ toPx,
13
+ } = require('../../utils/length');
14
+ const { buildScaleOptions } = require('../../utils/options');
15
+ const {
16
+ createTokenRegex,
17
+ declarationValueIndex,
18
+ isKeyword,
19
+ isMathFunction,
20
+ isTokenFunction,
21
+ propertyMatches,
22
+ walkRootValueNodes,
23
+ walkTransformTranslateNodes,
24
+ } = require('../../utils/value-utils');
25
+
26
+ const ruleName = 'rhythmguard/use-scale';
27
+
28
+ const messages = stylelint.utils.ruleMessages(ruleName, {
29
+ invalidPreset: (presetName, presetNames) =>
30
+ `Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
31
+ rejected: (value, lower, upper) =>
32
+ `Unexpected off-scale spacing value "${value}". Use spacing scale values (nearest: ${lower} or ${upper}).`,
33
+ });
34
+
35
+ function getFixedNodeValue(parsedLength, nearestPx, options) {
36
+ const unit = parsedLength.unit || 'px';
37
+
38
+ if (unit === '%' || !options.units.includes(unit)) {
39
+ return null;
40
+ }
41
+
42
+ const signedNearest = parsedLength.number < 0 ? -Math.abs(nearestPx) : nearestPx;
43
+ const converted = fromPx(signedNearest, unit, options.baseFontSize);
44
+
45
+ if (converted === null) {
46
+ return null;
47
+ }
48
+
49
+ return formatLength(converted, unit);
50
+ }
51
+
52
+ function checkLengthValue({
53
+ decl,
54
+ node,
55
+ options,
56
+ report,
57
+ scalePx,
58
+ }) {
59
+ const parsedLength = parseLengthToken(node.value);
60
+
61
+ if (!parsedLength) {
62
+ return false;
63
+ }
64
+
65
+ if (parsedLength.number === 0) {
66
+ return false;
67
+ }
68
+
69
+ if (parsedLength.unit === '%' && options.allowPercentages) {
70
+ return false;
71
+ }
72
+
73
+ if (!options.allowNegative && parsedLength.number < 0) {
74
+ report(node.value, decl, node);
75
+ return false;
76
+ }
77
+
78
+ if (
79
+ parsedLength.unit &&
80
+ parsedLength.unit !== '%' &&
81
+ !options.units.includes(parsedLength.unit)
82
+ ) {
83
+ return false;
84
+ }
85
+
86
+ const pxValue = toPx(Math.abs(parsedLength.number), parsedLength.unit, options.baseFontSize);
87
+
88
+ if (pxValue === null) {
89
+ return false;
90
+ }
91
+
92
+ const isOnScale = scalePx.some((scaleValue) => numbersEqual(scaleValue, pxValue));
93
+ if (isOnScale) {
94
+ return false;
95
+ }
96
+
97
+ const nearest = nearestScaleValues(pxValue, scalePx);
98
+ if (!nearest) {
99
+ return false;
100
+ }
101
+
102
+ const fixedValue = options.fixToScale
103
+ ? getFixedNodeValue(parsedLength, nearest.nearest, options)
104
+ : null;
105
+
106
+ report(node.value, decl, node, nearest, fixedValue);
107
+ return true;
108
+ }
109
+
110
+ const ruleFunction = (primary, secondaryOptions) => {
111
+ return (root, result) => {
112
+ const valid = stylelint.utils.validateOptions(result, ruleName, {
113
+ actual: primary,
114
+ possible: [true],
115
+ });
116
+
117
+ if (!valid) {
118
+ return;
119
+ }
120
+
121
+ const options = buildScaleOptions(secondaryOptions);
122
+ if (options.invalidPreset) {
123
+ stylelint.utils.report({
124
+ message: messages.invalidPreset(options.invalidPreset, options.presetNames),
125
+ node: root,
126
+ result,
127
+ ruleName,
128
+ });
129
+ }
130
+
131
+ const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
132
+ const scalePx = normalizeScale(options.scale, options.baseFontSize);
133
+
134
+ const report = (value, decl, node, nearest, fixedValue = null) => {
135
+ const index = declarationValueIndex(decl) + node.sourceIndex;
136
+ const endIndex = index + node.value.length;
137
+ const lower = nearest ? formatLength(nearest.lower, 'px') : 'n/a';
138
+ const upper = nearest ? formatLength(nearest.upper, 'px') : 'n/a';
139
+
140
+ const payload = {
141
+ endIndex,
142
+ index,
143
+ message: messages.rejected(value, lower, upper),
144
+ node: decl,
145
+ result,
146
+ ruleName,
147
+ };
148
+
149
+ if (fixedValue) {
150
+ payload.fix = () => {
151
+ node.value = fixedValue;
152
+ return true;
153
+ };
154
+ }
155
+
156
+ stylelint.utils.report(payload);
157
+ };
158
+
159
+ root.walkDecls((decl) => {
160
+ const prop = decl.prop.toLowerCase();
161
+ if (prop.startsWith('--')) {
162
+ return;
163
+ }
164
+
165
+ if (!propertyMatches(prop, options.properties)) {
166
+ return;
167
+ }
168
+
169
+ const parsed = valueParser(decl.value);
170
+ let changed = false;
171
+
172
+ if (prop === 'transform') {
173
+ walkTransformTranslateNodes(parsed, (node) => {
174
+ changed =
175
+ checkLengthValue({
176
+ decl,
177
+ node,
178
+ options,
179
+ report,
180
+ scalePx,
181
+ }) || changed;
182
+ });
183
+ } else {
184
+ walkRootValueNodes(parsed, (node, parentFunctionName) => {
185
+ if (node.type === 'function') {
186
+ if (isTokenFunction(node, options.tokenFunctions, tokenRegex)) {
187
+ return true;
188
+ }
189
+
190
+ if (
191
+ isMathFunction(node.value) &&
192
+ !options.enforceInsideMathFunctions
193
+ ) {
194
+ return true;
195
+ }
196
+
197
+ return false;
198
+ }
199
+
200
+ if (node.type !== 'word') {
201
+ return false;
202
+ }
203
+
204
+ if (isKeyword(node.value, options.ignoreValues)) {
205
+ return false;
206
+ }
207
+
208
+ if (
209
+ parentFunctionName &&
210
+ isMathFunction(parentFunctionName) &&
211
+ !options.enforceInsideMathFunctions
212
+ ) {
213
+ return false;
214
+ }
215
+
216
+ changed =
217
+ checkLengthValue({
218
+ decl,
219
+ node,
220
+ options,
221
+ report,
222
+ scalePx,
223
+ }) || changed;
224
+
225
+ return false;
226
+ });
227
+ }
228
+
229
+ if (changed) {
230
+ decl.value = parsed.toString();
231
+ }
232
+ });
233
+ };
234
+ };
235
+
236
+ ruleFunction.ruleName = ruleName;
237
+ ruleFunction.messages = messages;
238
+ ruleFunction.meta = {
239
+ fixable: true,
240
+ url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguarduse-scale',
241
+ };
242
+
243
+ module.exports = stylelint.createPlugin(ruleName, ruleFunction);
244
+ module.exports.ruleName = ruleName;
245
+ module.exports.messages = messages;
246
+ module.exports.meta = ruleFunction.meta;
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const SPACING_PROPERTY_PATTERNS = [
4
+ /^margin(?:-.+)?$/,
5
+ /^padding(?:-.+)?$/,
6
+ /^gap$/,
7
+ /^row-gap$/,
8
+ /^column-gap$/,
9
+ /^inset(?:-.+)?$/,
10
+ /^scroll-margin(?:-.+)?$/,
11
+ /^scroll-padding(?:-.+)?$/,
12
+ /^translate$/,
13
+ /^translate-[xyz]$/,
14
+ /^transform$/,
15
+ ];
16
+
17
+ const DEFAULT_IGNORE_KEYWORDS = [
18
+ 'auto',
19
+ 'inherit',
20
+ 'initial',
21
+ 'unset',
22
+ 'revert',
23
+ 'revert-layer',
24
+ ];
25
+
26
+ const TRANSLATE_FUNCTIONS = new Set([
27
+ 'translate',
28
+ 'translatex',
29
+ 'translatey',
30
+ 'translatez',
31
+ 'translate3d',
32
+ ]);
33
+
34
+ const MATH_FUNCTIONS = new Set(['calc', 'clamp', 'min', 'max']);
35
+
36
+ module.exports = {
37
+ DEFAULT_IGNORE_KEYWORDS,
38
+ MATH_FUNCTIONS,
39
+ SPACING_PROPERTY_PATTERNS,
40
+ TRANSLATE_FUNCTIONS,
41
+ };
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ const LENGTH_RE = /^(-?(?:\d+|\d*\.\d+))(px|rem|em|%)?$/i;
4
+ const EPSILON = 0.0001;
5
+
6
+ function parseLengthToken(rawValue) {
7
+ if (typeof rawValue !== 'string') {
8
+ return null;
9
+ }
10
+
11
+ const value = rawValue.trim();
12
+ const match = value.match(LENGTH_RE);
13
+
14
+ if (!match) {
15
+ return null;
16
+ }
17
+
18
+ const number = Number(match[1]);
19
+ const unit = (match[2] || '').toLowerCase();
20
+
21
+ if (!Number.isFinite(number)) {
22
+ return null;
23
+ }
24
+
25
+ return { number, raw: value, unit };
26
+ }
27
+
28
+ function toPx(number, unit, baseFontSize) {
29
+ if (unit === '' || unit === 'px') {
30
+ return number;
31
+ }
32
+
33
+ if (unit === 'rem' || unit === 'em') {
34
+ return number * baseFontSize;
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ function fromPx(pxValue, unit, baseFontSize) {
41
+ if (unit === '' || unit === 'px') {
42
+ return pxValue;
43
+ }
44
+
45
+ if (unit === 'rem' || unit === 'em') {
46
+ return pxValue / baseFontSize;
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ function formatNumber(value) {
53
+ if (Math.abs(value) < EPSILON) {
54
+ return '0';
55
+ }
56
+
57
+ const rounded = Math.round(value * 10000) / 10000;
58
+ return String(rounded).replace(/\.?0+$/, '');
59
+ }
60
+
61
+ function formatLength(number, unit) {
62
+ if (number === 0) {
63
+ if (unit === 'px') {
64
+ return '0px';
65
+ }
66
+
67
+ if (unit === 'rem' || unit === 'em' || unit === '') {
68
+ return '0';
69
+ }
70
+ }
71
+
72
+ return `${formatNumber(number)}${unit}`;
73
+ }
74
+
75
+ function numbersEqual(a, b) {
76
+ return Math.abs(a - b) < EPSILON;
77
+ }
78
+
79
+ function normalizeScale(scale, baseFontSize) {
80
+ const normalized = [];
81
+
82
+ for (const entry of scale) {
83
+ if (typeof entry === 'number') {
84
+ normalized.push(entry);
85
+ continue;
86
+ }
87
+
88
+ const parsed = parseLengthToken(String(entry));
89
+ if (!parsed) {
90
+ continue;
91
+ }
92
+
93
+ const px = toPx(parsed.number, parsed.unit, baseFontSize);
94
+ if (px !== null) {
95
+ normalized.push(px);
96
+ }
97
+ }
98
+
99
+ return [...new Set(normalized)].sort((a, b) => a - b);
100
+ }
101
+
102
+ function nearestScaleValues(targetPx, scalePx) {
103
+ if (scalePx.length === 0) {
104
+ return null;
105
+ }
106
+
107
+ let lower = scalePx[0];
108
+ let upper = scalePx[scalePx.length - 1];
109
+
110
+ for (const value of scalePx) {
111
+ if (value <= targetPx) {
112
+ lower = value;
113
+ }
114
+
115
+ if (value >= targetPx) {
116
+ upper = value;
117
+ break;
118
+ }
119
+ }
120
+
121
+ const nearest =
122
+ Math.abs(targetPx - lower) <= Math.abs(upper - targetPx) ? lower : upper;
123
+
124
+ return {
125
+ lower,
126
+ nearest,
127
+ upper,
128
+ };
129
+ }
130
+
131
+ module.exports = {
132
+ formatLength,
133
+ normalizeScale,
134
+ numbersEqual,
135
+ parseLengthToken,
136
+ toPx,
137
+ fromPx,
138
+ nearestScaleValues,
139
+ };
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ DEFAULT_IGNORE_KEYWORDS,
5
+ SPACING_PROPERTY_PATTERNS,
6
+ } = require('./constants');
7
+ const {
8
+ getScalePreset,
9
+ listScalePresetNames,
10
+ resolveScaleSelection,
11
+ } = require('../presets/scales');
12
+
13
+ const DEFAULT_SCALE = getScalePreset('rhythmic-4') || [0, 4, 8, 12, 16, 24, 32];
14
+
15
+ function buildScaleOptions(rawOptions) {
16
+ const options = rawOptions || {};
17
+ const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
18
+
19
+ return {
20
+ allowNegative: options.allowNegative !== false,
21
+ allowPercentages: options.allowPercentages !== false,
22
+ baseFontSize:
23
+ typeof options.baseFontSize === 'number' &&
24
+ Number.isFinite(options.baseFontSize) &&
25
+ options.baseFontSize > 0
26
+ ? options.baseFontSize
27
+ : 16,
28
+ enforceInsideMathFunctions: options.enforceInsideMathFunctions === true,
29
+ fixToScale: options.fixToScale !== false,
30
+ ignoreValues: Array.isArray(options.ignoreValues)
31
+ ? options.ignoreValues.map((value) => String(value).toLowerCase())
32
+ : DEFAULT_IGNORE_KEYWORDS,
33
+ invalidPreset: scaleSelection.invalidPreset,
34
+ preset: scaleSelection.selectedPreset,
35
+ presetNames: listScalePresetNames(),
36
+ properties: Array.isArray(options.properties)
37
+ ? options.properties
38
+ : SPACING_PROPERTY_PATTERNS,
39
+ scale: scaleSelection.scale,
40
+ tokenFunctions: Array.isArray(options.tokenFunctions)
41
+ ? options.tokenFunctions.map((value) => String(value).toLowerCase())
42
+ : ['var', 'theme', 'token'],
43
+ tokenPattern:
44
+ typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0
45
+ ? options.tokenPattern
46
+ : '^--space-',
47
+ units: Array.isArray(options.units)
48
+ ? options.units.map((unit) => String(unit).toLowerCase())
49
+ : ['px', 'rem', 'em'],
50
+ };
51
+ }
52
+
53
+ function buildTokenOptions(rawOptions) {
54
+ const options = rawOptions || {};
55
+ const scaleSelection = resolveScaleSelection(options, DEFAULT_SCALE);
56
+
57
+ return {
58
+ allowNumericScale: options.allowNumericScale === true,
59
+ baseFontSize:
60
+ typeof options.baseFontSize === 'number' &&
61
+ Number.isFinite(options.baseFontSize) &&
62
+ options.baseFontSize > 0
63
+ ? options.baseFontSize
64
+ : 16,
65
+ ignoreValues: Array.isArray(options.ignoreValues)
66
+ ? options.ignoreValues.map((value) => String(value).toLowerCase())
67
+ : DEFAULT_IGNORE_KEYWORDS,
68
+ invalidPreset: scaleSelection.invalidPreset,
69
+ preset: scaleSelection.selectedPreset,
70
+ presetNames: listScalePresetNames(),
71
+ properties: Array.isArray(options.properties)
72
+ ? options.properties
73
+ : SPACING_PROPERTY_PATTERNS,
74
+ scale: scaleSelection.scale,
75
+ tokenFunctions: Array.isArray(options.tokenFunctions)
76
+ ? options.tokenFunctions.map((value) => String(value).toLowerCase())
77
+ : ['var', 'theme', 'token'],
78
+ tokenMap:
79
+ options.tokenMap && typeof options.tokenMap === 'object'
80
+ ? options.tokenMap
81
+ : {},
82
+ tokenPattern:
83
+ typeof options.tokenPattern === 'string' && options.tokenPattern.length > 0
84
+ ? options.tokenPattern
85
+ : '^--space-',
86
+ };
87
+ }
88
+
89
+ module.exports = {
90
+ buildScaleOptions,
91
+ buildTokenOptions,
92
+ };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ const valueParser = require('postcss-value-parser');
4
+ const stylelint = require('stylelint');
5
+ const {
6
+ MATH_FUNCTIONS,
7
+ TRANSLATE_FUNCTIONS,
8
+ } = require('./constants');
9
+
10
+ function propertyMatches(prop, patterns) {
11
+ const normalized = prop.toLowerCase();
12
+ return patterns.some((pattern) => {
13
+ if (pattern instanceof RegExp) {
14
+ return pattern.test(normalized);
15
+ }
16
+
17
+ return String(pattern).toLowerCase() === normalized;
18
+ });
19
+ }
20
+
21
+ function isKeyword(value, ignoreValues) {
22
+ return ignoreValues.includes(String(value).toLowerCase());
23
+ }
24
+
25
+ function createTokenRegex(tokenPattern, result, ruleName) {
26
+ try {
27
+ return new RegExp(tokenPattern);
28
+ } catch {
29
+ stylelint.utils.report({
30
+ message: `Invalid tokenPattern regex: ${tokenPattern}`,
31
+ result,
32
+ ruleName,
33
+ });
34
+
35
+ return /^--space-/;
36
+ }
37
+ }
38
+
39
+ function isTokenFunction(node, tokenFunctions, tokenRegex) {
40
+ if (node.type !== 'function') {
41
+ return false;
42
+ }
43
+
44
+ const fn = node.value.toLowerCase();
45
+ if (!tokenFunctions.includes(fn)) {
46
+ return false;
47
+ }
48
+
49
+ if (fn !== 'var') {
50
+ return true;
51
+ }
52
+
53
+ const firstArg = valueParser.stringify(node.nodes).split(',')[0].trim();
54
+ return tokenRegex.test(firstArg);
55
+ }
56
+
57
+ function walkRootValueNodes(parsed, walkNode, state) {
58
+ const walkNodes = (nodes, parentFunctionName) => {
59
+ for (const node of nodes) {
60
+ if (node.type === 'function') {
61
+ const fnName = node.value.toLowerCase();
62
+ const skipChildren = walkNode(node, parentFunctionName);
63
+
64
+ if (skipChildren) {
65
+ continue;
66
+ }
67
+
68
+ walkNodes(node.nodes, fnName);
69
+ continue;
70
+ }
71
+
72
+ walkNode(node, parentFunctionName);
73
+ }
74
+ };
75
+
76
+ walkNodes(parsed.nodes, state || null);
77
+ }
78
+
79
+ function walkTransformTranslateNodes(parsed, walkNode) {
80
+ for (const node of parsed.nodes) {
81
+ if (node.type !== 'function') {
82
+ continue;
83
+ }
84
+
85
+ if (!TRANSLATE_FUNCTIONS.has(node.value.toLowerCase())) {
86
+ continue;
87
+ }
88
+
89
+ for (const child of node.nodes) {
90
+ if (child.type === 'word') {
91
+ walkNode(child, node.value.toLowerCase());
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ function isMathFunction(functionName) {
98
+ if (!functionName) {
99
+ return false;
100
+ }
101
+
102
+ return MATH_FUNCTIONS.has(functionName.toLowerCase());
103
+ }
104
+
105
+ function declarationValueIndex(decl) {
106
+ const declarationText = decl.toString();
107
+ const idx = declarationText.indexOf(decl.value);
108
+ return idx === -1 ? 0 : idx;
109
+ }
110
+
111
+ module.exports = {
112
+ createTokenRegex,
113
+ declarationValueIndex,
114
+ isKeyword,
115
+ isMathFunction,
116
+ isTokenFunction,
117
+ propertyMatches,
118
+ walkRootValueNodes,
119
+ walkTransformTranslateNodes,
120
+ };