stylelint-plugin-rhythmguard 3.12.0 → 3.13.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 +40 -0
- package/README.md +1 -1
- package/package.json +23 -1
- package/src/audit/args.js +23 -4
- package/src/audit/baseline.js +15 -2
- package/src/audit/codemod.js +56 -5
- package/src/audit/config.js +2 -1
- package/src/audit/contract.js +3 -0
- package/src/audit/decisions.js +45 -16
- package/src/audit/render-markdown.js +22 -21
- package/src/audit/render-text.js +1 -0
- package/src/audit/render-utils.js +28 -1
- package/src/audit/report.js +14 -2
- package/src/audit/scan/files.js +139 -30
- package/src/audit/token-chains.js +4 -1
- package/src/audit/token-ladders.js +23 -2
- package/src/cli/doctor.js +28 -3
- package/src/cli/fix.js +7 -6
- package/src/cli/index.js +2 -2
- package/src/cli/init.js +41 -5
- package/src/cli/quickstart.js +46 -8
- package/src/configs/embed.js +2 -0
- package/src/configs/motion.js +1 -0
- package/src/configs/recommended.js +1 -0
- package/src/configs/strict.js +1 -0
- package/src/configs/tailwind.js +1 -0
- package/src/core/decisions.js +2 -1
- package/src/core/json.js +25 -0
- package/src/core/length.js +11 -0
- package/src/core/options.js +39 -20
- package/src/core/scale-inference.js +78 -23
- package/src/core/tailwind-class-analysis.js +3 -2
- package/src/core/token-map.js +2 -1
- package/src/core/token-sources.js +2 -1
- package/src/eslint/index.js +14 -8
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +1 -0
- package/src/eslint/rules/tailwind-class-use-scale.js +1 -0
- package/src/rules/no-offscale-transform/index.js +58 -53
- package/src/rules/prefer-token/index.js +31 -46
- package/src/rules/use-motion-scale/index.js +14 -1
- package/src/rules/use-scale/index.js +75 -102
- package/src/rules/validate.js +13 -4
- package/types/__checks__/consumer.ts +20 -1
- package/types/__checks__/eslint-config.ts +8 -0
- package/types/__checks__/shipped-configs.ts +96 -0
- package/types/audit.d.ts +101 -16
- package/types/index.d.ts +15 -0
- package/types/shared.d.ts +126 -20
package/src/cli/quickstart.js
CHANGED
|
@@ -16,6 +16,16 @@ const { createAuditReport } = require('../audit/report');
|
|
|
16
16
|
const { detect } = require('./init');
|
|
17
17
|
const { discoverTokenPackages } = require('../core/scale-inference');
|
|
18
18
|
|
|
19
|
+
const HELP = `Usage: rhythmguard [quickstart]
|
|
20
|
+
|
|
21
|
+
Zero-config first run. Detects the stack and token files, infers the spacing
|
|
22
|
+
scale, audits the current directory, prints a config to paste and the audit
|
|
23
|
+
commands that reproduce its result. Never fails on findings; this is a report.
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--help Show this help message
|
|
27
|
+
`;
|
|
28
|
+
|
|
19
29
|
const TOKEN_FILE_PATTERN = /(^|[.-])tokens?\.json$/i;
|
|
20
30
|
const TOKEN_DIRS = ['tokens', 'design-tokens', path.join('src', 'tokens'), path.join('dist', 'tokens')];
|
|
21
31
|
const MAX_LISTED = 5;
|
|
@@ -116,6 +126,26 @@ function eslintSnippet(scaleValues) {
|
|
|
116
126
|
].join('\n');
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
function shellQuote(value) {
|
|
130
|
+
return /^[\w./@%+=:,-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The commands to run next. The audit commands pass what quickstart passed,
|
|
135
|
+
* `--scale auto` and the token files it read, so they report the same scale
|
|
136
|
+
* and the same findings; `audit .` alone would use the default scale (#287).
|
|
137
|
+
*/
|
|
138
|
+
function nextCommands(tokenSources) {
|
|
139
|
+
const audit = ['npx rhythmguard audit .', '--scale auto', ...tokenSources.map((file) => `--token-source ${shellQuote(toPosix(file))}`)].join(' ');
|
|
140
|
+
// Each note on its own line above its command, so a long command copies whole.
|
|
141
|
+
return [
|
|
142
|
+
['Lint with the config above:', 'npx stylelint "**/*.css"'],
|
|
143
|
+
['PR-ready report:', `${audit} --format markdown`],
|
|
144
|
+
['Then gate only new drift in CI, with the same flags:', `${audit} --write-baseline`],
|
|
145
|
+
['Check the setup:', 'npx rhythmguard doctor'],
|
|
146
|
+
].flatMap(([note, command]) => [note, ` ${command}`]);
|
|
147
|
+
}
|
|
148
|
+
|
|
119
149
|
function describeScale(scale) {
|
|
120
150
|
if (!scale) {
|
|
121
151
|
return 'default';
|
|
@@ -125,6 +155,17 @@ function describeScale(scale) {
|
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
async function run() {
|
|
158
|
+
// `rhythmguard` alone has no arguments; `rhythmguard quickstart` may have --help (#286).
|
|
159
|
+
for (const arg of process.argv[2] === 'quickstart' ? process.argv.slice(3) : []) {
|
|
160
|
+
if (arg === '--help' || arg === '-h') {
|
|
161
|
+
process.stdout.write(HELP);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
process.stderr.write(`Unknown option: ${arg}\n\n${HELP}`);
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
128
169
|
const cwd = process.cwd();
|
|
129
170
|
const pkg = readPackage(cwd);
|
|
130
171
|
const stack = detect();
|
|
@@ -144,14 +185,14 @@ async function run() {
|
|
|
144
185
|
out.push(` .rhythmguardrc ${rcPresent ? 'present (its token sources are used)' : 'none'}`);
|
|
145
186
|
out.push('');
|
|
146
187
|
|
|
188
|
+
// With an rc file its token sources are used; otherwise the token files found here.
|
|
189
|
+
const auditTokenSources = rcPresent ? [] : tokenFiles;
|
|
147
190
|
let report;
|
|
148
191
|
try {
|
|
149
192
|
report = await createAuditReport({
|
|
150
193
|
dir: '.',
|
|
151
194
|
scale: 'auto',
|
|
152
|
-
...(
|
|
153
|
-
? { tokenSources: tokenFiles }
|
|
154
|
-
: {}),
|
|
195
|
+
...(auditTokenSources.length > 0 ? { tokenSources: auditTokenSources } : {}),
|
|
155
196
|
});
|
|
156
197
|
} catch (error) {
|
|
157
198
|
process.stderr.write(`Quickstart could not audit ${cwd}: ${error.message}\n`);
|
|
@@ -228,13 +269,10 @@ async function run() {
|
|
|
228
269
|
}
|
|
229
270
|
|
|
230
271
|
out.push(' Next');
|
|
231
|
-
out.push(
|
|
232
|
-
out.push(' npx rhythmguard audit . --format markdown PR-ready report');
|
|
233
|
-
out.push(' npx rhythmguard audit . --write-baseline then gate only new drift in CI');
|
|
234
|
-
out.push(' npx rhythmguard doctor check the setup');
|
|
272
|
+
out.push(...nextCommands(auditTokenSources).map((line) => ` ${line}`));
|
|
235
273
|
out.push('');
|
|
236
274
|
|
|
237
275
|
process.stdout.write(`${out.join('\n')}\n`);
|
|
238
276
|
}
|
|
239
277
|
|
|
240
|
-
module.exports = { eslintSnippet, findTokenFiles, run, suggestedStylelintConfig, tailwindMajor };
|
|
278
|
+
module.exports = { HELP, eslintSnippet, findTokenFiles, nextCommands, run, suggestedStylelintConfig, tailwindMajor };
|
package/src/configs/embed.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* No `extends`, no dependency beyond this plugin, and the shape is frozen for
|
|
10
10
|
* the life of a major version so a config that embeds it never changes
|
|
11
11
|
* behaviour on a minor bump.
|
|
12
|
+
*
|
|
13
|
+
* @type {import('../../types/shared').RhythmguardStylelintConfig}
|
|
12
14
|
*/
|
|
13
15
|
module.exports = {
|
|
14
16
|
plugins: ['stylelint-plugin-rhythmguard'],
|
package/src/configs/motion.js
CHANGED
package/src/configs/strict.js
CHANGED
package/src/configs/tailwind.js
CHANGED
package/src/core/decisions.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const { cachedByFiles } = require('./fs-cache');
|
|
6
|
+
const { readJsonFile } = require('./json');
|
|
6
7
|
const { numbersEqual, parseLengthToken, toPx } = require('./length');
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -105,7 +106,7 @@ function loadRcDecisions(cwd = process.cwd(), { baseFontSize = 16 } = {}) {
|
|
|
105
106
|
}
|
|
106
107
|
let config;
|
|
107
108
|
try {
|
|
108
|
-
config =
|
|
109
|
+
config = readJsonFile(rcPath);
|
|
109
110
|
} catch {
|
|
110
111
|
return [];
|
|
111
112
|
}
|
package/src/core/json.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
|
|
5
|
+
const BYTE_ORDER_MARK = /^\uFEFF/;
|
|
6
|
+
|
|
7
|
+
/** Text without a leading UTF-8 byte order mark, which some Windows editors write and JSON.parse rejects (issue #291). */
|
|
8
|
+
function stripBom(text) {
|
|
9
|
+
return String(text).replace(BYTE_ORDER_MARK, '');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** JSON.parse for files people edit: a leading byte order mark is tolerated. */
|
|
13
|
+
function parseJson(text) {
|
|
14
|
+
return JSON.parse(stripBom(text));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonFile(file) {
|
|
18
|
+
return parseJson(fs.readFileSync(file, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
parseJson,
|
|
23
|
+
readJsonFile,
|
|
24
|
+
stripBom,
|
|
25
|
+
};
|
package/src/core/length.js
CHANGED
|
@@ -224,6 +224,16 @@ function fixedLengthValue(parsedLength, nearestPx, { baseFontSize, unitStrategy,
|
|
|
224
224
|
return converted === null ? null : formatLength(converted, unit);
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** " (16.8px)" after a bound written in rem or em, so the reader sees what was compared. */
|
|
228
|
+
function pxAside(value, baseFontSize) {
|
|
229
|
+
const parsed = parseLengthToken(value);
|
|
230
|
+
if (!parsed || !parsed.unit || parsed.unit === 'px' || parsed.unit === '%') {
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
const px = toPx(Math.abs(parsed.number), parsed.unit, baseFontSize);
|
|
234
|
+
return px === null ? '' : ` (${formatLength(parsed.number < 0 ? -px : px, 'px')})`;
|
|
235
|
+
}
|
|
236
|
+
|
|
227
237
|
module.exports = {
|
|
228
238
|
nearestText,
|
|
229
239
|
fixedLengthValue,
|
|
@@ -236,6 +246,7 @@ module.exports = {
|
|
|
236
246
|
normalizeScaleByUnit,
|
|
237
247
|
numbersEqual,
|
|
238
248
|
parseLengthToken,
|
|
249
|
+
pxAside,
|
|
239
250
|
pxOnScale,
|
|
240
251
|
toPx,
|
|
241
252
|
};
|
package/src/core/options.js
CHANGED
|
@@ -92,8 +92,14 @@ function isAutoScaleLiteral(value) {
|
|
|
92
92
|
return value === 'auto';
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
/** A list of scale values. An empty list checks nothing, so it is not a scale (issue #273). */
|
|
96
|
+
function isScaleList(value) {
|
|
97
|
+
return Array.isArray(value) && value.length > 0 && value.every((entry) => isScaleEntry(entry));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A name `getScalePreset` knows, built-in or community, aliases included. */
|
|
101
|
+
function isKnownPresetName(value) {
|
|
102
|
+
return typeof value === 'string' && Boolean(getScalePreset(value));
|
|
97
103
|
}
|
|
98
104
|
|
|
99
105
|
function isScaleSourceEntry(value) {
|
|
@@ -213,9 +219,18 @@ function isMathFunctionArgumentMap(value) {
|
|
|
213
219
|
});
|
|
214
220
|
}
|
|
215
221
|
|
|
222
|
+
/**
|
|
223
|
+
* A scale given as values, a preset name, or a { preset | scale | customScale }
|
|
224
|
+
* object. An empty list or an unknown preset would leave the scale empty or
|
|
225
|
+
* fall back to the default without a word, so neither is valid (issue #273).
|
|
226
|
+
*/
|
|
216
227
|
function isScaleOverrideEntry(value) {
|
|
217
228
|
if (Array.isArray(value)) {
|
|
218
|
-
return value
|
|
229
|
+
return isScaleList(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (typeof value === 'string') {
|
|
233
|
+
return isKnownPresetName(value);
|
|
219
234
|
}
|
|
220
235
|
|
|
221
236
|
if (!isPlainObject(value)) {
|
|
@@ -230,18 +245,15 @@ function isScaleOverrideEntry(value) {
|
|
|
230
245
|
return false;
|
|
231
246
|
}
|
|
232
247
|
|
|
233
|
-
if (hasPreset && !
|
|
248
|
+
if (hasPreset && !isKnownPresetName(value.preset)) {
|
|
234
249
|
return false;
|
|
235
250
|
}
|
|
236
251
|
|
|
237
|
-
if (hasScale &&
|
|
252
|
+
if (hasScale && !isScaleList(value.scale)) {
|
|
238
253
|
return false;
|
|
239
254
|
}
|
|
240
255
|
|
|
241
|
-
if (
|
|
242
|
-
hasCustomScale &&
|
|
243
|
-
(!Array.isArray(value.customScale) || !value.customScale.every((entry) => isScaleEntry(entry)))
|
|
244
|
-
) {
|
|
256
|
+
if (hasCustomScale && !isScaleList(value.customScale)) {
|
|
245
257
|
return false;
|
|
246
258
|
}
|
|
247
259
|
|
|
@@ -267,13 +279,7 @@ const SCALED_GROUPS = Object.freeze(['spacing', 'radius', 'size', 'typography'])
|
|
|
267
279
|
|
|
268
280
|
/** One group's scale: values, a preset name, "auto", or a { preset | scale | customScale } object. */
|
|
269
281
|
function isGroupScaleEntry(value) {
|
|
270
|
-
|
|
271
|
-
return true;
|
|
272
|
-
}
|
|
273
|
-
if (typeof value === 'string') {
|
|
274
|
-
return Boolean(getScalePreset(value));
|
|
275
|
-
}
|
|
276
|
-
return isScaleOverrideEntry(value);
|
|
282
|
+
return isAutoScaleLiteral(value) || isScaleOverrideEntry(value);
|
|
277
283
|
}
|
|
278
284
|
|
|
279
285
|
function isGroupScalesMap(value) {
|
|
@@ -293,7 +299,7 @@ function buildGroupScales(scales) {
|
|
|
293
299
|
if (isAutoScaleLiteral(entry)) {
|
|
294
300
|
return [group, { auto: true, scale: null }];
|
|
295
301
|
}
|
|
296
|
-
const scale =
|
|
302
|
+
const scale = resolveScaleOverride(entry);
|
|
297
303
|
return [group, { auto: false, scale: Array.isArray(scale) ? [...scale] : null }];
|
|
298
304
|
}));
|
|
299
305
|
}
|
|
@@ -302,7 +308,9 @@ function buildPossibleOptionMap(schema) {
|
|
|
302
308
|
return Object.fromEntries(
|
|
303
309
|
Object.entries(schema).map(([optionName, descriptor]) => [
|
|
304
310
|
optionName,
|
|
305
|
-
|
|
311
|
+
// A literal such as "auto" stands for the whole option; validateSecondaryOptionShapes
|
|
312
|
+
// rejects it inside an array (issue #273).
|
|
313
|
+
[...(descriptor.allowLiterals || []), descriptor.entryValidator || VALIDATE_ALWAYS],
|
|
306
314
|
]),
|
|
307
315
|
);
|
|
308
316
|
}
|
|
@@ -359,6 +367,12 @@ function resolveScaleOverride(override) {
|
|
|
359
367
|
return override;
|
|
360
368
|
}
|
|
361
369
|
|
|
370
|
+
// A preset name, as `propertyScales: { padding: "rhythmic-8" }` (issue #273).
|
|
371
|
+
if (typeof override === 'string') {
|
|
372
|
+
const preset = getScalePreset(override);
|
|
373
|
+
return preset ? [...preset] : null;
|
|
374
|
+
}
|
|
375
|
+
|
|
362
376
|
if (!isPlainObject(override)) {
|
|
363
377
|
return null;
|
|
364
378
|
}
|
|
@@ -438,6 +452,7 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
438
452
|
customScale: Object.freeze({
|
|
439
453
|
entryValidator: isScaleEntry,
|
|
440
454
|
expectsArray: true,
|
|
455
|
+
nonEmpty: true,
|
|
441
456
|
}),
|
|
442
457
|
checkMathBounds: Object.freeze({
|
|
443
458
|
entryValidator: isBoolean,
|
|
@@ -467,8 +482,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
467
482
|
}),
|
|
468
483
|
scale: Object.freeze({
|
|
469
484
|
allowLiterals: ['auto'],
|
|
470
|
-
entryValidator:
|
|
485
|
+
entryValidator: isScaleEntry,
|
|
471
486
|
expectsArray: true,
|
|
487
|
+
nonEmpty: true,
|
|
472
488
|
}),
|
|
473
489
|
scaleSources: Object.freeze({
|
|
474
490
|
entryValidator: isScaleSourceEntry,
|
|
@@ -536,6 +552,7 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
|
536
552
|
customScale: Object.freeze({
|
|
537
553
|
entryValidator: isScaleEntry,
|
|
538
554
|
expectsArray: true,
|
|
555
|
+
nonEmpty: true,
|
|
539
556
|
}),
|
|
540
557
|
checkMathBounds: Object.freeze({
|
|
541
558
|
entryValidator: isBoolean,
|
|
@@ -579,8 +596,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
|
579
596
|
}),
|
|
580
597
|
scale: Object.freeze({
|
|
581
598
|
allowLiterals: ['auto'],
|
|
582
|
-
entryValidator:
|
|
599
|
+
entryValidator: isScaleEntry,
|
|
583
600
|
expectsArray: true,
|
|
601
|
+
nonEmpty: true,
|
|
584
602
|
}),
|
|
585
603
|
scaleSources: Object.freeze({
|
|
586
604
|
entryValidator: isScaleSourceEntry,
|
|
@@ -809,6 +827,7 @@ module.exports = {
|
|
|
809
827
|
PREFER_TOKEN_VALIDATION_SCHEMA,
|
|
810
828
|
USE_SCALE_POSSIBLE_OPTIONS,
|
|
811
829
|
USE_SCALE_VALIDATION_SCHEMA,
|
|
830
|
+
buildGroupScales,
|
|
812
831
|
buildScaleOptions,
|
|
813
832
|
buildTokenOptions,
|
|
814
833
|
SCALED_GROUPS,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
+
const { readJsonFile } = require('./json');
|
|
6
7
|
const { parseLengthToken, toPx } = require('./length');
|
|
7
8
|
const { buildEffectiveTokenMap } = require('./token-map');
|
|
8
9
|
const {
|
|
@@ -18,7 +19,14 @@ const { getScalePreset } = require('../presets/scales');
|
|
|
18
19
|
// Matches the audit default so lint and audit agree on what a spacing token is.
|
|
19
20
|
const DEFAULT_AUTO_TOKEN_PATTERN = '(^--|-)(?<!letter-)(?<!word-)(space|spacing|spacer)(-|$)';
|
|
20
21
|
// Tailwind v4 defines one base (`--spacing: 0.25rem`) and derives utilities by multiplying it.
|
|
21
|
-
|
|
22
|
+
// A bare `--space` is not Tailwind's; it is one token like any other (issue #293).
|
|
23
|
+
const TAILWIND_BASE_TOKENS = new Set(['--spacing']);
|
|
24
|
+
// A bare `--space` is a base only when the other spacing tokens do not form a
|
|
25
|
+
// scale on their own. Discourse declares `--space: 0.25rem` and writes every
|
|
26
|
+
// step as `calc(var(--space) * N)`, so its scale is the multiples; a ladder of
|
|
27
|
+
// plain lengths (`--space-s`, `--space-m`) next to `--space` is the scale, and
|
|
28
|
+
// `--space` is one more step on it (issue #293).
|
|
29
|
+
const CONDITIONAL_BASE_TOKENS = new Set(['--space']);
|
|
22
30
|
const TAILWIND_SPACING_MULTIPLIERS = [
|
|
23
31
|
0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4, 5, 6, 7, 8, 9, 10, 11, 12, 14, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 72, 80, 96,
|
|
24
32
|
];
|
|
@@ -38,7 +46,7 @@ const TOKEN_PACKAGES = require('./token-packages.json').packages;
|
|
|
38
46
|
*/
|
|
39
47
|
function readDirectDependencies(dir) {
|
|
40
48
|
try {
|
|
41
|
-
const pkg =
|
|
49
|
+
const pkg = readJsonFile(path.join(dir, 'package.json'));
|
|
42
50
|
return new Set([
|
|
43
51
|
...Object.keys(pkg.dependencies || {}),
|
|
44
52
|
...Object.keys(pkg.devDependencies || {}),
|
|
@@ -171,13 +179,38 @@ function parseSourcesCached(sources, baseFontSize) {
|
|
|
171
179
|
return warnings.length > 0 ? { ...parsed, warnings: [...warnings, ...parsed.warnings] } : parsed;
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Token sources read for the spacing names and, when a rule sets its own
|
|
184
|
+
* `tokenPattern` (`^--space-|^--radius-`), for that pattern too, on top of the
|
|
185
|
+
* spacing names, unless a source sets its own. Fixes and scale inference read
|
|
186
|
+
* the same tokens through this (issue #296).
|
|
187
|
+
*/
|
|
188
|
+
function parseSourcesWithPattern(sources, baseFontSize, tokenPattern = null) {
|
|
189
|
+
const plain = parseSourcesCached(sources, baseFontSize);
|
|
190
|
+
if (!tokenPattern || !plain) {
|
|
191
|
+
return plain;
|
|
192
|
+
}
|
|
193
|
+
const withPattern = parseSourcesCached(
|
|
194
|
+
sources.map((source) => (typeof source === 'string' ? { path: source, tokenPattern } : { tokenPattern, ...source })),
|
|
195
|
+
baseFontSize,
|
|
196
|
+
);
|
|
197
|
+
const definitions = new Map(plain.definitions);
|
|
198
|
+
for (const [token, definition] of withPattern ? withPattern.definitions : []) {
|
|
199
|
+
if (!definitions.has(token)) {
|
|
200
|
+
definitions.set(token, definition);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const warnings = Array.from(new Set([...plain.warnings, ...(withPattern ? withPattern.warnings : [])]));
|
|
204
|
+
return { definitions, sources: plain.sources, warnings };
|
|
205
|
+
}
|
|
206
|
+
|
|
174
207
|
/**
|
|
175
208
|
* The scale a list of token sources defines, or null. Warnings from sources
|
|
176
209
|
* that are missing or do not parse go into `warnings` either way, so a
|
|
177
210
|
* fallback can say which source failed (issue #270).
|
|
178
211
|
*/
|
|
179
|
-
function scaleFromSources(sources, baseFontSize, warnings = []) {
|
|
180
|
-
const parsed =
|
|
212
|
+
function scaleFromSources(sources, baseFontSize, warnings = [], tokenPattern = null) {
|
|
213
|
+
const parsed = parseSourcesWithPattern(sources, baseFontSize, tokenPattern);
|
|
181
214
|
if (!parsed) {
|
|
182
215
|
return null;
|
|
183
216
|
}
|
|
@@ -203,16 +236,10 @@ function scaleFromSources(sources, baseFontSize, warnings = []) {
|
|
|
203
236
|
function collectTokenDefinitions({ baseFontSize = 16, root, scaleSources = [], tokenPattern = null, tokenRegex }) {
|
|
204
237
|
const definitions = root ? stylesheetDefinitions(root, tokenRegex, baseFontSize) : new Map();
|
|
205
238
|
const sources = [...scaleSources, ...rcTokenSources(process.cwd()).sources, ...discoverTokenPackages(process.cwd())];
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
: [];
|
|
211
|
-
for (const parsed of [parseSourcesCached(sources, baseFontSize), parseSourcesCached(withPattern, baseFontSize)]) {
|
|
212
|
-
for (const [token, definition] of parsed ? parsed.definitions : []) {
|
|
213
|
-
if (!definitions.has(token)) {
|
|
214
|
-
definitions.set(token, definition);
|
|
215
|
-
}
|
|
239
|
+
const parsed = parseSourcesWithPattern(sources, baseFontSize, tokenPattern);
|
|
240
|
+
for (const [token, definition] of parsed ? parsed.definitions : []) {
|
|
241
|
+
if (!definitions.has(token)) {
|
|
242
|
+
definitions.set(token, definition);
|
|
216
243
|
}
|
|
217
244
|
}
|
|
218
245
|
return definitions;
|
|
@@ -252,17 +279,36 @@ function scaleFromDefinitions(definitions, baseFontSize = 16) {
|
|
|
252
279
|
function scaleFromAllDefinitions(definitions, baseFontSize) {
|
|
253
280
|
const keys = [];
|
|
254
281
|
const baseKeys = [];
|
|
282
|
+
const conditionalKeys = [];
|
|
255
283
|
for (const definition of definitions.values()) {
|
|
256
284
|
if (TAILWIND_BASE_TOKENS.has(definition.token)) {
|
|
257
285
|
baseKeys.push(...definition.normalizedValues);
|
|
258
286
|
continue;
|
|
259
287
|
}
|
|
288
|
+
if (CONDITIONAL_BASE_TOKENS.has(definition.token)) {
|
|
289
|
+
conditionalKeys.push(...definition.normalizedValues);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
260
292
|
keys.push(...definition.normalizedValues);
|
|
261
293
|
}
|
|
262
|
-
const scale =
|
|
294
|
+
const scale = scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys });
|
|
263
295
|
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
264
296
|
}
|
|
265
297
|
|
|
298
|
+
/**
|
|
299
|
+
* The scale from plain token values, expanded by a base multiplier where one
|
|
300
|
+
* applies: `--spacing` always, a bare `--space` only when the plain values do
|
|
301
|
+
* not already form a scale (see CONDITIONAL_BASE_TOKENS).
|
|
302
|
+
*/
|
|
303
|
+
function scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys }) {
|
|
304
|
+
const plain = pxValuesFromKeys(keys, baseFontSize);
|
|
305
|
+
if (baseKeys.length > 0 || plain.length >= MIN_INFERRED_SCALE_LENGTH || conditionalKeys.length === 0) {
|
|
306
|
+
const withConditional = conditionalKeys.length > 0 ? pxValuesFromKeys([...keys, ...conditionalKeys], baseFontSize) : plain;
|
|
307
|
+
return expandTailwindBase(withConditional, baseKeys, baseFontSize);
|
|
308
|
+
}
|
|
309
|
+
return expandTailwindBase(plain, conditionalKeys, baseFontSize);
|
|
310
|
+
}
|
|
311
|
+
|
|
266
312
|
/** The scope of a declaration node: the AST-side twin of customPropertyDeclarations. */
|
|
267
313
|
function scopeOfNode(decl) {
|
|
268
314
|
for (let node = decl.parent; node; node = node.parent) {
|
|
@@ -302,8 +348,8 @@ function stylesheetDefinitions(root, tokenRegex, baseFontSize) {
|
|
|
302
348
|
}
|
|
303
349
|
|
|
304
350
|
/**
|
|
305
|
-
* Merge Tailwind-style base multiples into a scale when a bare --spacing
|
|
306
|
-
*
|
|
351
|
+
* Merge Tailwind-style base multiples into a scale when a bare --spacing base
|
|
352
|
+
* is defined. Only the first base found is expanded: a project that ships
|
|
307
353
|
* several theme files with different bases (shadcn/ui) has one active base at a
|
|
308
354
|
* time, and a union of ladders is a scale nobody designed (issue #89).
|
|
309
355
|
*/
|
|
@@ -354,7 +400,7 @@ function rcTokenSourcesUncached(rcPath) {
|
|
|
354
400
|
|
|
355
401
|
let config;
|
|
356
402
|
try {
|
|
357
|
-
config =
|
|
403
|
+
config = readJsonFile(rcPath);
|
|
358
404
|
} catch (err) {
|
|
359
405
|
return { sources: [], warnings: [`${RC_FILE} could not be read: ${err.message}`] };
|
|
360
406
|
}
|
|
@@ -400,39 +446,47 @@ function sassTokensFromRoot(root) {
|
|
|
400
446
|
function scaleFromTokenMap(map, baseFontSize, extraKeys = []) {
|
|
401
447
|
const keys = [...extraKeys];
|
|
402
448
|
const baseKeys = [];
|
|
449
|
+
const conditionalKeys = [];
|
|
403
450
|
for (const [key, reference] of Object.entries(map)) {
|
|
404
451
|
const name = String(reference).match(/^var\((--[\w-]+)\)$/);
|
|
405
452
|
if (name && TAILWIND_BASE_TOKENS.has(name[1])) {
|
|
406
453
|
baseKeys.push(key);
|
|
454
|
+
} else if (name && CONDITIONAL_BASE_TOKENS.has(name[1])) {
|
|
455
|
+
conditionalKeys.push(key);
|
|
407
456
|
} else {
|
|
408
457
|
keys.push(key);
|
|
409
458
|
}
|
|
410
459
|
}
|
|
411
|
-
const scale =
|
|
460
|
+
const scale = scaleWithBase({ baseFontSize, baseKeys, conditionalKeys, keys });
|
|
412
461
|
return scale.length >= MIN_INFERRED_SCALE_LENGTH ? scale : null;
|
|
413
462
|
}
|
|
414
463
|
|
|
415
464
|
/**
|
|
416
465
|
* Resolve `scale: "auto"`. First matching source wins; sources are not merged so
|
|
417
|
-
* the provenance is a single file list or the stylesheet.
|
|
466
|
+
* the provenance is a single file list or the stylesheet. The order, which
|
|
467
|
+
* docs/rules/use-scale.md documents: `scaleSources`, `.rhythmguardrc.json`
|
|
468
|
+
* token sources, the linted stylesheet, `tailwindConfigPath`, installed token
|
|
469
|
+
* packages, the fallback preset. `sourceTokenPattern` is the rule's own
|
|
470
|
+
* explicit `tokenPattern`, applied to the token files as fixes apply it.
|
|
418
471
|
*/
|
|
419
472
|
function resolveAutoScale({
|
|
420
473
|
baseFontSize = 16,
|
|
421
474
|
root,
|
|
422
475
|
scaleSources = [],
|
|
476
|
+
sourceTokenPattern = null,
|
|
423
477
|
tailwindConfigPath = null,
|
|
424
478
|
tokenPattern = DEFAULT_AUTO_TOKEN_PATTERN,
|
|
425
479
|
} = {}) {
|
|
426
480
|
// Warnings from every source tried, for the fallback note when none yields a scale.
|
|
427
481
|
const warnings = [];
|
|
428
|
-
const fromOption = scaleFromSources(scaleSources, baseFontSize, warnings);
|
|
482
|
+
const fromOption = scaleFromSources(scaleSources, baseFontSize, warnings, sourceTokenPattern);
|
|
429
483
|
if (fromOption) {
|
|
430
484
|
return { source: 'scaleSources', ...fromOption };
|
|
431
485
|
}
|
|
432
486
|
|
|
433
487
|
const rc = rcTokenSources(process.cwd());
|
|
434
488
|
warnings.push(...rc.warnings);
|
|
435
|
-
const fromRc = scaleFromSources(rc.sources, baseFontSize, warnings);
|
|
489
|
+
const fromRc = scaleFromSources(rc.sources, baseFontSize, warnings, sourceTokenPattern);
|
|
436
490
|
if (fromRc) {
|
|
437
491
|
return { source: 'rhythmguardrc', ...fromRc };
|
|
438
492
|
}
|
|
@@ -473,7 +527,7 @@ function resolveAutoScale({
|
|
|
473
527
|
}
|
|
474
528
|
}
|
|
475
529
|
|
|
476
|
-
const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize, warnings);
|
|
530
|
+
const fromPackages = scaleFromSources(discoverTokenPackages(process.cwd()), baseFontSize, warnings, sourceTokenPattern);
|
|
477
531
|
if (fromPackages) {
|
|
478
532
|
return { source: 'token-package', ...fromPackages };
|
|
479
533
|
}
|
|
@@ -546,6 +600,7 @@ function withResolvedScale(options, root) {
|
|
|
546
600
|
baseFontSize: options.baseFontSize,
|
|
547
601
|
root,
|
|
548
602
|
scaleSources: options.scaleSources,
|
|
603
|
+
sourceTokenPattern: options.tokenPatternExplicit ? options.tokenPattern : null,
|
|
549
604
|
tailwindConfigPath: options.tailwindConfigPath,
|
|
550
605
|
tokenPattern: options.tokenPatternExplicit ? options.tokenPattern : DEFAULT_AUTO_TOKEN_PATTERN,
|
|
551
606
|
});
|
|
@@ -172,8 +172,9 @@ function analyzeClassToken(token, options, scalePx) {
|
|
|
172
172
|
return null;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// `-m-[16px]` and `m-[-16px]` are both negative margins (#241)
|
|
176
|
-
|
|
175
|
+
// `-m-[16px]` and `m-[-16px]` are both negative margins (#241); in
|
|
176
|
+
// `-m-[-16px]` the two signs cancel and Tailwind writes a positive margin (#277).
|
|
177
|
+
const negative = match.groups.utility.startsWith('-') !== parsedLength.number < 0;
|
|
177
178
|
|
|
178
179
|
if (!options.allowNegative && negative) {
|
|
179
180
|
return {
|
package/src/core/token-map.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { readJsonFile } = require('./json');
|
|
3
4
|
const { parseTokenValueLength } = require('./token-sources');
|
|
4
5
|
|
|
5
6
|
const { spawnSync } = require('node:child_process');
|
|
@@ -140,7 +141,7 @@ function mergeTokenMapFromFile({
|
|
|
140
141
|
|
|
141
142
|
let parsed;
|
|
142
143
|
try {
|
|
143
|
-
parsed =
|
|
144
|
+
parsed = readJsonFile(resolvedPath);
|
|
144
145
|
} catch {
|
|
145
146
|
return currentMap;
|
|
146
147
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
|
|
6
|
+
const { stripBom } = require('./json');
|
|
6
7
|
const {
|
|
7
8
|
formatLength,
|
|
8
9
|
parseLengthToken,
|
|
@@ -116,7 +117,7 @@ function parseTokenSources({
|
|
|
116
117
|
|
|
117
118
|
let text;
|
|
118
119
|
try {
|
|
119
|
-
text = fs.readFileSync(normalizedSource.resolvedPath, 'utf8');
|
|
120
|
+
text = stripBom(fs.readFileSync(normalizedSource.resolvedPath, 'utf8'));
|
|
120
121
|
} catch (err) {
|
|
121
122
|
const warning = `Unable to read token source ${normalizedSource.displayPath}: ${err.message}`;
|
|
122
123
|
sourceReport.warnings.push(warning);
|
package/src/eslint/index.js
CHANGED
|
@@ -3,17 +3,23 @@
|
|
|
3
3
|
const tailwindClassUseScale = require('./rules/tailwind-class-use-scale');
|
|
4
4
|
const tailwindClassUseMotionScale = require('./rules/tailwind-class-use-motion-scale');
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
const plugin = {
|
|
7
7
|
rules: {
|
|
8
8
|
'tailwind-class-use-scale': tailwindClassUseScale,
|
|
9
9
|
'tailwind-class-use-motion-scale': tailwindClassUseMotionScale,
|
|
10
10
|
},
|
|
11
|
-
configs: {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
configs: {},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// A flat config that works on its own (`export default [rhythmguard.configs.recommended]`),
|
|
15
|
+
// so it registers the plugin under the prefix its rules use (#279). Spreading
|
|
16
|
+
// `configs.recommended.rules` into a block that registers the plugin itself still works.
|
|
17
|
+
plugin.configs.recommended = {
|
|
18
|
+
plugins: { 'rhythmguard-tailwind': plugin },
|
|
19
|
+
rules: {
|
|
20
|
+
'rhythmguard-tailwind/tailwind-class-use-scale': 'warn',
|
|
21
|
+
'rhythmguard-tailwind/tailwind-class-use-motion-scale': 'off',
|
|
18
22
|
},
|
|
19
23
|
};
|
|
24
|
+
|
|
25
|
+
module.exports = plugin;
|
|
@@ -54,6 +54,7 @@ function buildMessage(analysis, segment, lower, upper) {
|
|
|
54
54
|
|
|
55
55
|
module.exports = {
|
|
56
56
|
meta: {
|
|
57
|
+
type: 'suggestion',
|
|
57
58
|
docs: {
|
|
58
59
|
url: 'https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/rules/tailwind-class-use-motion-scale.md',
|
|
59
60
|
description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',
|