stylelint-plugin-rhythmguard 3.6.0 → 3.7.1
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 +24 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -2
- package/agents/claude-code/SKILL.md +1 -1
- package/agents/copilot/copilot-instructions.md +1 -1
- package/agents/cursor/rhythmguard.mdc +1 -1
- package/package.json +2 -2
- package/src/audit/scan/templates.js +2 -4
- package/src/core/length.js +1 -0
- package/src/core/options.js +28 -0
- package/src/core/tailwind-class-analysis.js +59 -3
- package/src/core/token-index.js +10 -0
- package/src/core/token-map.js +50 -13
- package/src/core/token-sources.js +53 -1
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +6 -2
- package/src/eslint/rules/tailwind-class-use-scale.js +13 -13
- package/src/rules/no-offscale-transform/index.js +4 -3
- package/src/rules/prefer-token/index.js +17 -10
- package/src/rules/use-motion-scale/index.js +12 -8
- package/src/rules/use-scale/index.js +3 -2
- package/types/shared.d.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [3.7.1] - 2026-09-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Sass maps declared through a merge helper are read: `$spacers: defaults((...), $spacers)` (Bootstrap v6), `map.merge($base, (...))` and `map-merge((...), (...))` contribute the merged map, later keys winning. The reader also follows Sass assignment rules now: a later plain assignment replaces an earlier `!default`, a later `!default` does not. Before, the first assignment always won, so Bootstrap v6's `$spacers: () !default;` hid the map that followed it and `scale: "auto"` fell back. ([#143](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/143))
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- The quiet benchmark has a `bootstrap-v6` row, the branch its maintainers asked to see (twbs/bootstrap#42907); the manifest accepts a `ref` so a row can pin a branch other than the default.
|
|
18
|
+
- The release verify matrix and the post-publish smoke are back on the self-hosted farm. The repository variable `RELEASE_ON_HOSTED=true` moves a release onto GitHub-hosted runners while the farm is down, without a workflow edit or a re-cut; the release checklist explains when to use it.
|
|
19
|
+
|
|
20
|
+
## [3.7.0] - 2026-09-17
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Every rule accepts `note`, a sentence of up to 200 characters appended to each finding after the built-in text: `... (nearest: 12px or 16px). See docs/spacing.md for approved exceptions.` The built-in text is unchanged, so tooling that matches on it keeps working. Stylelint and ESLint rules alike. ([#134](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/134))
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `prefer-token` names the token it would write and where it was read from: `Unexpected raw scale value "12px". Use var(--space-3) (src/tokens.css).` The origin is the linted stylesheet, the `tokenMapFile` or the Tailwind config; an inline `tokenMap` entry is named without one, and a negative literal as `calc(-1 * var(--space-3))`. When no mapping holds the value the message says `No known token holds this value; use the nearest token or add one.` It said `Use design tokens for scale decisions.` in every case. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
|
|
29
|
+
- `use-scale` and `no-offscale-transform` with `fixWith: "token"` append `var(--space-3) holds 12px.` when exactly one token qualifies, so the message names what the fix writes. ([#133](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/133))
|
|
30
|
+
- `tailwind-class-use-scale` names the utility classes for the two nearest steps, with their px values, and its fix writes the utility class instead of an on-scale arbitrary value: `p-[13px]` reads `Use "p-3" (12px) or "p-4" (16px)` and becomes `p-3`; `md:-m-[13px]!` becomes `md:-m-3!`; `p-[0.8125rem]` becomes `p-3`. The class number is the snapped value divided by the new `spacingUnit` option (4px, Tailwind's `--spacing`); when it is not a quarter step the fix keeps the arbitrary form, and `spacingUnit: false` keeps it always. The audit's Tailwind findings carry the same text and `fixedToken`. ([#132](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/issues/132))
|
|
31
|
+
- The release workflow verifies and publishes on GitHub-hosted runners, and the post-publish smoke runs there too. The first 3.7.0 release waited 24 hours for a self-hosted runner that never registered and was cancelled; a release no longer depends on the farm. Pushes and pull requests still use it.
|
|
32
|
+
|
|
9
33
|
## [3.6.0] - 2026-09-09
|
|
10
34
|
|
|
11
35
|
### Added
|
package/CONTRIBUTING.md
CHANGED
|
@@ -106,7 +106,7 @@ The maintainer aims to respond to issues and PRs within a week. Small PRs merge
|
|
|
106
106
|
|
|
107
107
|
## Releases
|
|
108
108
|
|
|
109
|
-
Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
|
|
109
|
+
Maintainer-run. Bump `package.json` and `CHANGELOG.md`, create a GitHub release, and `release.yml` verifies on the self-hosted matrix and publishes through npm trusted publishing (OIDC) from a GitHub-hosted job. The checklist covers cutting a release while the farm is down. Provenance is automatic. The checklist is in [`docs/RELEASE_CHECKLIST.md`](./docs/RELEASE_CHECKLIST.md).
|
|
110
110
|
|
|
111
111
|
## Benchmarking performance
|
|
112
112
|
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=
|
|
2
|
+
<img src="https://raw.githubusercontent.com/petrilahdelma/stylelint-plugin-rhythmguard/main/assets/rhythmguard-banner.svg?v=11" width="100%" alt="Rhythmguard: stable local evidence for design system drift" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# stylelint-plugin-rhythmguard
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
Nobody chose 13px. Rhythmguard catches off-scale spacing in CSS and Tailwind class strings, tells you the nearest steps on your scale, and snaps to them or to your tokens when you ask.
|
|
8
8
|
|
|
9
9
|
[](https://github.com/petrilahdelma/stylelint-plugin-rhythmguard/actions/workflows/ci.yml)
|
|
10
|
-
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
11
11
|
[](https://www.npmjs.com/package/stylelint-plugin-rhythmguard)
|
|
12
12
|
[](./LICENSE)
|
|
13
13
|
|
|
@@ -11,7 +11,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
|
11
11
|
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
12
12
|
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
13
13
|
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
14
|
-
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too
|
|
14
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
|
|
15
15
|
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
16
16
|
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
17
17
|
|
|
@@ -6,7 +6,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
|
6
6
|
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
7
7
|
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
8
8
|
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
9
|
-
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too
|
|
9
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
|
|
10
10
|
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
11
11
|
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
12
12
|
|
|
@@ -12,7 +12,7 @@ This project enforces its spacing scale with stylelint-plugin-rhythmguard.
|
|
|
12
12
|
- A finding names the value and the two nearest on-scale values. Prefer the design token for that value when one exists; otherwise use the nearest on-scale value. Never invent a new spacing value.
|
|
13
13
|
- Do not use `--fix` on spacing findings unless the task says so. Snapping a value can change layout; choose the value deliberately.
|
|
14
14
|
- Values of one pixel or less (hairlines) and percentages are allowed and are not findings.
|
|
15
|
-
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too
|
|
15
|
+
- Tailwind arbitrary spacing values such as `p-[13px]` are findings too. The finding names the two utility classes to choose from (`p-3` or `p-4`); write one of those, not another arbitrary value.
|
|
16
16
|
- When the audit's `contracts.scale.offScaleProperties` table is dominated by margins on sibling elements, put a `gap` on the parent instead of fixing each margin. The parent owns the spacing between its children.
|
|
17
17
|
- If the audit reports the scale source as `fallback`, the project has no discoverable spacing tokens. Ask before adding any; do not guess a scale.
|
|
18
18
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.1",
|
|
4
4
|
"description": "Nobody chose 13px. Catches off-scale spacing in CSS and Tailwind class strings and snaps it to your scale or tokens. Stylelint rules, an ESLint companion, and an audit CLI.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"rhythmguard": "src/cli/index.js"
|
|
@@ -153,7 +153,7 @@
|
|
|
153
153
|
"devDependencies": {
|
|
154
154
|
"@eslint/js": "^9.39.5",
|
|
155
155
|
"@types/node": "^22.20.1",
|
|
156
|
-
"c8": "^
|
|
156
|
+
"c8": "^12.0.0",
|
|
157
157
|
"eslint": "^9.39.5",
|
|
158
158
|
"postcss-scss": "^4.0.9",
|
|
159
159
|
"stylelint": "^16.15.0",
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
const fs = require('node:fs');
|
|
8
8
|
const { formatLength } = require('../../core/length');
|
|
9
|
-
const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
|
|
9
|
+
const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
|
|
10
10
|
const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
|
|
11
11
|
const { formatTime } = require('../../core/time');
|
|
12
12
|
const {
|
|
@@ -43,9 +43,7 @@ function collectTailwindFindings(templateFiles, options) {
|
|
|
43
43
|
: null,
|
|
44
44
|
rawValue: analysis.rawValue,
|
|
45
45
|
rule: 'rhythmguard-tailwind/tailwind-class-use-scale',
|
|
46
|
-
text:
|
|
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.`,
|
|
46
|
+
text: offScaleClassMessage(segment.token, analysis),
|
|
49
47
|
token: segment.token,
|
|
50
48
|
type: 'tailwind-arbitrary-spacing',
|
|
51
49
|
utility: analysis.utility,
|
package/src/core/length.js
CHANGED
package/src/core/options.js
CHANGED
|
@@ -54,6 +54,22 @@ function isNonEmptyString(value) {
|
|
|
54
54
|
return typeof value === 'string' && value.trim().length > 0;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
const MAX_NOTE_LENGTH = 200;
|
|
58
|
+
|
|
59
|
+
/** A `note` is appended to every finding, so it stays a sentence: non-empty, at most 200 characters. */
|
|
60
|
+
function isNote(value) {
|
|
61
|
+
return isNonEmptyString(value) && value.trim().length <= MAX_NOTE_LENGTH;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeNote(value) {
|
|
65
|
+
return isNote(value) ? value.trim() : '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The message with the configured note after it, or the message alone. */
|
|
69
|
+
function withNote(message, note) {
|
|
70
|
+
return note ? `${message} ${note}` : message;
|
|
71
|
+
}
|
|
72
|
+
|
|
57
73
|
function isSupportedUnit(value) {
|
|
58
74
|
if (!isNonEmptyString(value)) {
|
|
59
75
|
return false;
|
|
@@ -399,6 +415,9 @@ const SCALE_VALIDATION_SCHEMA = Object.freeze({
|
|
|
399
415
|
entryValidator: isMathFunctionArgumentMap,
|
|
400
416
|
expectsObject: true,
|
|
401
417
|
}),
|
|
418
|
+
note: Object.freeze({
|
|
419
|
+
entryValidator: isNote,
|
|
420
|
+
}),
|
|
402
421
|
preset: Object.freeze({
|
|
403
422
|
entryValidator: isNonEmptyString,
|
|
404
423
|
}),
|
|
@@ -483,6 +502,9 @@ const PREFER_TOKEN_VALIDATION_SCHEMA = Object.freeze({
|
|
|
483
502
|
entryValidator: isMathFunctionArgumentMap,
|
|
484
503
|
expectsObject: true,
|
|
485
504
|
}),
|
|
505
|
+
note: Object.freeze({
|
|
506
|
+
entryValidator: isNote,
|
|
507
|
+
}),
|
|
486
508
|
preset: Object.freeze({
|
|
487
509
|
entryValidator: isNonEmptyString,
|
|
488
510
|
}),
|
|
@@ -573,6 +595,7 @@ function buildScaleOptions(rawOptions) {
|
|
|
573
595
|
: DEFAULT_IGNORE_KEYWORDS,
|
|
574
596
|
invalidPreset: scaleSelection.invalidPreset,
|
|
575
597
|
mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
|
|
598
|
+
note: normalizeNote(options.note),
|
|
576
599
|
preset: scaleSelection.selectedPreset,
|
|
577
600
|
presetNames: listScalePresetNames(),
|
|
578
601
|
properties: resolvePropertyPatterns(options),
|
|
@@ -618,6 +641,7 @@ function buildTokenOptions(rawOptions) {
|
|
|
618
641
|
: DEFAULT_IGNORE_KEYWORDS,
|
|
619
642
|
invalidPreset: scaleSelection.invalidPreset,
|
|
620
643
|
mathFunctionArguments: normalizeMathFunctionArgumentMap(options.mathFunctionArguments),
|
|
644
|
+
note: normalizeNote(options.note),
|
|
621
645
|
preset: scaleSelection.selectedPreset,
|
|
622
646
|
presetNames: listScalePresetNames(),
|
|
623
647
|
properties: resolvePropertyPatterns(options),
|
|
@@ -701,6 +725,10 @@ function resolvePropertyScale(prop, options) {
|
|
|
701
725
|
}
|
|
702
726
|
|
|
703
727
|
module.exports = {
|
|
728
|
+
MAX_NOTE_LENGTH,
|
|
729
|
+
isNote,
|
|
730
|
+
normalizeNote,
|
|
731
|
+
withNote,
|
|
704
732
|
NO_OFFSCALE_TRANSFORM_POSSIBLE_OPTIONS,
|
|
705
733
|
NO_OFFSCALE_TRANSFORM_VALIDATION_SCHEMA,
|
|
706
734
|
PREFER_TOKEN_POSSIBLE_OPTIONS,
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const {
|
|
4
4
|
formatLength,
|
|
5
|
+
formatNumber,
|
|
5
6
|
nearestScaleValues,
|
|
6
7
|
normalizeScale,
|
|
7
8
|
numbersEqual,
|
|
@@ -11,6 +12,9 @@ const {
|
|
|
11
12
|
|
|
12
13
|
const DEFAULT_SCALE = [0, 4, 8, 12, 16, 24, 32];
|
|
13
14
|
const DEFAULT_UNITS = ['px', 'rem', 'em'];
|
|
15
|
+
// Tailwind's numeric spacing utilities are multiples of one unit: `--spacing`
|
|
16
|
+
// (0.25rem, 4px) in v4, and the same 4px step in the v3 default scale.
|
|
17
|
+
const DEFAULT_SPACING_UNIT_PX = 4;
|
|
14
18
|
|
|
15
19
|
const ARBITRARY_SPACING_CLASS = /^(?<utility>-?(?:m(?:[trblxy])?|p(?:[trblxy])?|gap(?:-[xy])?|space-[xy]|inset(?:-[xy])?|top|right|bottom|left|translate-[xy]|scroll-(?:m|p)(?:[trblxy])?))-\[(?<rawValue>[^\]]+)\]$/;
|
|
16
20
|
|
|
@@ -24,6 +28,11 @@ function normalizeTailwindClassOptions(option = {}) {
|
|
|
24
28
|
? option.baseFontSize
|
|
25
29
|
: 16,
|
|
26
30
|
scale: Array.isArray(option.scale) ? option.scale : DEFAULT_SCALE,
|
|
31
|
+
spacingUnit: option.spacingUnit === false
|
|
32
|
+
? null
|
|
33
|
+
: typeof option.spacingUnit === 'number' && Number.isFinite(option.spacingUnit) && option.spacingUnit > 0
|
|
34
|
+
? option.spacingUnit
|
|
35
|
+
: DEFAULT_SPACING_UNIT_PX,
|
|
27
36
|
units: Array.isArray(option.units)
|
|
28
37
|
? option.units.map((unit) => String(unit).toLowerCase())
|
|
29
38
|
: DEFAULT_UNITS,
|
|
@@ -121,6 +130,26 @@ function hasInvalidVariantPrefix(prefix) {
|
|
|
121
130
|
return false;
|
|
122
131
|
}
|
|
123
132
|
|
|
133
|
+
/**
|
|
134
|
+
* The utility class Tailwind generates for a px value, or null when the value
|
|
135
|
+
* is not a quarter step of the spacing unit (v4 generates any quarter step;
|
|
136
|
+
* v3 generates the quarter steps of its default scale). `p-3` for 12px on a
|
|
137
|
+
* 4px unit; `-m-3` when the class or the value is negative.
|
|
138
|
+
*/
|
|
139
|
+
function utilityClassFor(utility, px, negative, spacingUnit) {
|
|
140
|
+
if (!spacingUnit) {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const steps = Math.abs(px) / spacingUnit;
|
|
144
|
+
const quarters = steps * 4;
|
|
145
|
+
if (Math.abs(quarters - Math.round(quarters)) > 1e-9) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const name = utility.startsWith('-') ? utility.slice(1) : utility;
|
|
149
|
+
const sign = negative && steps !== 0 ? '-' : '';
|
|
150
|
+
return `${sign}${name}-${formatNumber(Math.round(quarters) / 4)}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
124
153
|
function analyzeClassToken(token, options, scalePx) {
|
|
125
154
|
const parsedToken = parseClassToken(token);
|
|
126
155
|
if (parsedToken.prefix && hasInvalidVariantPrefix(parsedToken.prefix)) {
|
|
@@ -175,13 +204,22 @@ function analyzeClassToken(token, options, scalePx) {
|
|
|
175
204
|
? signedNearest
|
|
176
205
|
: signedNearest / options.baseFontSize;
|
|
177
206
|
|
|
207
|
+
const negative = match.groups.utility.startsWith('-') || parsedLength.number < 0;
|
|
208
|
+
const wrap = (candidate) =>
|
|
209
|
+
`${parsedToken.prefix}${parsedToken.leadingImportant}${candidate}${parsedToken.trailingImportant}`;
|
|
210
|
+
const utilityFix = utilityClassFor(match.groups.utility, nearest.nearest, negative, options.spacingUnit);
|
|
211
|
+
const lowerUtility = utilityClassFor(match.groups.utility, nearest.lower, negative, options.spacingUnit);
|
|
212
|
+
const upperUtility = utilityClassFor(match.groups.utility, nearest.upper, negative, options.spacingUnit);
|
|
213
|
+
|
|
178
214
|
const replacementValue = formatLength(replacementNumber, parsedLength.unit);
|
|
179
|
-
const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
180
|
-
const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
|
|
215
|
+
const fixedCandidate = utilityFix || parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
181
216
|
|
|
182
217
|
return {
|
|
183
|
-
fixedToken,
|
|
218
|
+
fixedToken: wrap(fixedCandidate),
|
|
184
219
|
nearest,
|
|
220
|
+
// The utility classes for the two nearest steps, when both are quarter
|
|
221
|
+
// steps of the spacing unit; messages name these instead of px values.
|
|
222
|
+
nearestUtilities: lowerUtility && upperUtility ? { lower: lowerUtility, upper: upperUtility } : null,
|
|
185
223
|
parsedLength,
|
|
186
224
|
reason: 'off-scale',
|
|
187
225
|
rawValue: match.groups.rawValue,
|
|
@@ -214,8 +252,26 @@ function createTailwindClassAnalyzer(option = {}) {
|
|
|
214
252
|
};
|
|
215
253
|
}
|
|
216
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The message text for an off-scale class: the two nearest utility classes
|
|
257
|
+
* with their px values when the unit makes them, the px steps otherwise.
|
|
258
|
+
*/
|
|
259
|
+
function offScaleClassMessage(token, analysis) {
|
|
260
|
+
if (analysis.reason === 'negative') {
|
|
261
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Negative values are disabled for this rule.`;
|
|
262
|
+
}
|
|
263
|
+
const lowerPx = analysis.nearest ? formatLength(analysis.nearest.lower, 'px') : 'n/a';
|
|
264
|
+
const upperPx = analysis.nearest ? formatLength(analysis.nearest.upper, 'px') : 'n/a';
|
|
265
|
+
if (analysis.nearestUtilities) {
|
|
266
|
+
const { lower, upper } = analysis.nearestUtilities;
|
|
267
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Use "${lower}" (${lowerPx}) or "${upper}" (${upperPx}).`;
|
|
268
|
+
}
|
|
269
|
+
return `Unexpected Tailwind arbitrary spacing value "${token}". Use scale values (nearest: ${lowerPx} or ${upperPx}).`;
|
|
270
|
+
}
|
|
271
|
+
|
|
217
272
|
module.exports = {
|
|
218
273
|
createTailwindClassAnalyzer,
|
|
219
274
|
findClassSegments,
|
|
220
275
|
normalizeTailwindClassOptions,
|
|
276
|
+
offScaleClassMessage,
|
|
221
277
|
};
|
package/src/core/token-index.js
CHANGED
|
@@ -64,9 +64,19 @@ function replacementFor(parsedLength, nearestPx, options) {
|
|
|
64
64
|
return fixedLengthValue(parsedLength, nearestPx, options);
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The sentence a length rule appends when its fix writes a token: which token
|
|
69
|
+
* holds the snapped value. Empty when the replacement is a literal.
|
|
70
|
+
*/
|
|
71
|
+
function tokenHoldsNote(replacement, formattedNearest) {
|
|
72
|
+
const match = typeof replacement === 'string' ? replacement.match(/var\(--[^)]+\)/) : null;
|
|
73
|
+
return match ? `${match[0]} holds ${formattedNearest}.` : '';
|
|
74
|
+
}
|
|
75
|
+
|
|
67
76
|
module.exports = {
|
|
68
77
|
negateReplacement,
|
|
69
78
|
replacementFor,
|
|
79
|
+
tokenHoldsNote,
|
|
70
80
|
tokenForLength,
|
|
71
81
|
tokenIndexFromDefinitions,
|
|
72
82
|
};
|
package/src/core/token-map.js
CHANGED
|
@@ -40,7 +40,19 @@ function normalizeTokenReference(tokenReference) {
|
|
|
40
40
|
return `var(--${trimmed})`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Where a token came from, for messages: the path relative to the working
|
|
45
|
+
* directory when it is inside it, the absolute path otherwise.
|
|
46
|
+
*/
|
|
47
|
+
function displayOrigin(file) {
|
|
48
|
+
if (!file) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const relative = path.relative(process.cwd(), file);
|
|
52
|
+
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize, origins = null, origin = null) {
|
|
44
56
|
if (typeof rawLength !== 'string') {
|
|
45
57
|
return;
|
|
46
58
|
}
|
|
@@ -55,6 +67,10 @@ function addLengthValueMapping(map, rawLength, tokenReference, baseFontSize) {
|
|
|
55
67
|
return;
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
if (origins && origin && !origins[token]) {
|
|
71
|
+
origins[token] = origin;
|
|
72
|
+
}
|
|
73
|
+
|
|
58
74
|
const absolute = Math.abs(parsed.number);
|
|
59
75
|
const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
|
|
60
76
|
map[normalizedRaw] = token;
|
|
@@ -81,7 +97,7 @@ function mergeExplicitTokenMap(target, source) {
|
|
|
81
97
|
return target;
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
function walkTokenGroup(map, group, prefix, baseFontSize) {
|
|
100
|
+
function walkTokenGroup(map, group, prefix, baseFontSize, origins, origin) {
|
|
85
101
|
for (const [key, value] of Object.entries(group)) {
|
|
86
102
|
const tokenName = `${prefix}-${key}`;
|
|
87
103
|
|
|
@@ -91,24 +107,25 @@ function walkTokenGroup(map, group, prefix, baseFontSize) {
|
|
|
91
107
|
|
|
92
108
|
// Leaf node with $value (DTCG)
|
|
93
109
|
if (typeof value.$value === 'string') {
|
|
94
|
-
addLengthValueMapping(map, value.$value, tokenName, baseFontSize);
|
|
110
|
+
addLengthValueMapping(map, value.$value, tokenName, baseFontSize, origins, origin);
|
|
95
111
|
continue;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
// Leaf node with value (Style Dictionary)
|
|
99
115
|
if (typeof value.value === 'string') {
|
|
100
|
-
addLengthValueMapping(map, value.value, tokenName, baseFontSize);
|
|
116
|
+
addLengthValueMapping(map, value.value, tokenName, baseFontSize, origins, origin);
|
|
101
117
|
continue;
|
|
102
118
|
}
|
|
103
119
|
|
|
104
|
-
// Nested group
|
|
105
|
-
walkTokenGroup(map, value, tokenName, baseFontSize);
|
|
120
|
+
// Nested group: recurse deeper
|
|
121
|
+
walkTokenGroup(map, value, tokenName, baseFontSize, origins, origin);
|
|
106
122
|
}
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
function mergeTokenMapFromFile({
|
|
110
126
|
baseFontSize,
|
|
111
127
|
currentMap,
|
|
128
|
+
origins = null,
|
|
112
129
|
tokenMapFile,
|
|
113
130
|
}) {
|
|
114
131
|
if (!tokenMapFile) {
|
|
@@ -134,6 +151,7 @@ function mergeTokenMapFromFile({
|
|
|
134
151
|
const nextMap = {
|
|
135
152
|
...currentMap,
|
|
136
153
|
};
|
|
154
|
+
const origin = displayOrigin(resolvedPath);
|
|
137
155
|
|
|
138
156
|
for (const [entryKey, entryValue] of Object.entries(parsed)) {
|
|
139
157
|
if (typeof entryValue === 'string') {
|
|
@@ -142,36 +160,39 @@ function mergeTokenMapFromFile({
|
|
|
142
160
|
|
|
143
161
|
if (keyAsLength) {
|
|
144
162
|
nextMap[entryKey] = entryValue;
|
|
163
|
+
if (origins && !origins[entryValue]) {
|
|
164
|
+
origins[entryValue] = origin;
|
|
165
|
+
}
|
|
145
166
|
continue;
|
|
146
167
|
}
|
|
147
168
|
|
|
148
169
|
if (valueAsLength) {
|
|
149
|
-
addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize);
|
|
170
|
+
addLengthValueMapping(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
continue;
|
|
153
174
|
}
|
|
154
175
|
|
|
155
176
|
if (typeof entryValue === 'number') {
|
|
156
|
-
addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize);
|
|
177
|
+
addLengthValueMapping(nextMap, `${entryValue}px`, entryKey, baseFontSize, origins, origin);
|
|
157
178
|
continue;
|
|
158
179
|
}
|
|
159
180
|
|
|
160
181
|
if (isPlainObject(entryValue)) {
|
|
161
182
|
// Style Dictionary format: { value: "16px" }
|
|
162
183
|
if (typeof entryValue.value === 'string') {
|
|
163
|
-
addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize);
|
|
184
|
+
addLengthValueMapping(nextMap, entryValue.value, entryKey, baseFontSize, origins, origin);
|
|
164
185
|
continue;
|
|
165
186
|
}
|
|
166
187
|
|
|
167
188
|
// W3C DTCG format: { $value: "16px", $type: "dimension" }
|
|
168
189
|
if (typeof entryValue.$value === 'string') {
|
|
169
|
-
addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize);
|
|
190
|
+
addLengthValueMapping(nextMap, entryValue.$value, entryKey, baseFontSize, origins, origin);
|
|
170
191
|
continue;
|
|
171
192
|
}
|
|
172
193
|
|
|
173
|
-
// Nested group
|
|
174
|
-
walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize);
|
|
194
|
+
// Nested group: recurse (e.g. { spacing: { 4: { $value: "16px" } } })
|
|
195
|
+
walkTokenGroup(nextMap, entryValue, entryKey, baseFontSize, origins, origin);
|
|
175
196
|
}
|
|
176
197
|
}
|
|
177
198
|
|
|
@@ -181,12 +202,14 @@ function mergeTokenMapFromFile({
|
|
|
181
202
|
function mergeTokenMapFromCssCustomProperties({
|
|
182
203
|
baseFontSize,
|
|
183
204
|
currentMap,
|
|
205
|
+
origins = null,
|
|
184
206
|
root,
|
|
185
207
|
tokenRegex,
|
|
186
208
|
}) {
|
|
187
209
|
const nextMap = {
|
|
188
210
|
...currentMap,
|
|
189
211
|
};
|
|
212
|
+
const origin = displayOrigin(root && root.source && root.source.input ? root.source.input.file : null);
|
|
190
213
|
|
|
191
214
|
root.walkDecls((decl) => {
|
|
192
215
|
const prop = decl.prop.toLowerCase();
|
|
@@ -203,7 +226,7 @@ function mergeTokenMapFromCssCustomProperties({
|
|
|
203
226
|
return;
|
|
204
227
|
}
|
|
205
228
|
|
|
206
|
-
addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize);
|
|
229
|
+
addLengthValueMapping(nextMap, decl.value, `var(${decl.prop})`, baseFontSize, origins, origin);
|
|
207
230
|
});
|
|
208
231
|
|
|
209
232
|
return nextMap;
|
|
@@ -321,6 +344,7 @@ function loadTailwindSpacing(resolvedPath) {
|
|
|
321
344
|
|
|
322
345
|
function mergeTokenMapFromTailwindSpacing({
|
|
323
346
|
currentMap,
|
|
347
|
+
origins = null,
|
|
324
348
|
tailwindConfigPath,
|
|
325
349
|
}) {
|
|
326
350
|
if (!tailwindConfigPath) {
|
|
@@ -354,13 +378,23 @@ function mergeTokenMapFromTailwindSpacing({
|
|
|
354
378
|
const absolute = Math.abs(parsed.number);
|
|
355
379
|
const normalizedRaw = formatLength(absolute, parsed.unit || 'px');
|
|
356
380
|
nextMap[normalizedRaw] = `theme(spacing.${key})`;
|
|
381
|
+
if (origins && !origins[nextMap[normalizedRaw]]) {
|
|
382
|
+
origins[nextMap[normalizedRaw]] = displayOrigin(resolvedPath);
|
|
383
|
+
}
|
|
357
384
|
}
|
|
358
385
|
|
|
359
386
|
return nextMap;
|
|
360
387
|
}
|
|
361
388
|
|
|
389
|
+
/**
|
|
390
|
+
* The raw-length to token-reference map a rule fixes with. Pass `origins` to
|
|
391
|
+
* also learn where each token reference came from (the token map file, the
|
|
392
|
+
* stylesheet, the Tailwind config), keyed by the reference; an explicit
|
|
393
|
+
* `tokenMap` entry has no origin.
|
|
394
|
+
*/
|
|
362
395
|
function buildEffectiveTokenMap({
|
|
363
396
|
options,
|
|
397
|
+
origins = null,
|
|
364
398
|
root,
|
|
365
399
|
tokenRegex,
|
|
366
400
|
}) {
|
|
@@ -370,6 +404,7 @@ function buildEffectiveTokenMap({
|
|
|
370
404
|
tokenMap = mergeTokenMapFromFile({
|
|
371
405
|
baseFontSize: options.baseFontSize,
|
|
372
406
|
currentMap: tokenMap,
|
|
407
|
+
origins,
|
|
373
408
|
tokenMapFile: options.tokenMapFile,
|
|
374
409
|
});
|
|
375
410
|
}
|
|
@@ -378,6 +413,7 @@ function buildEffectiveTokenMap({
|
|
|
378
413
|
tokenMap = mergeTokenMapFromCssCustomProperties({
|
|
379
414
|
baseFontSize: options.baseFontSize,
|
|
380
415
|
currentMap: tokenMap,
|
|
416
|
+
origins,
|
|
381
417
|
root,
|
|
382
418
|
tokenRegex,
|
|
383
419
|
});
|
|
@@ -386,6 +422,7 @@ function buildEffectiveTokenMap({
|
|
|
386
422
|
if (options.tokenMapFromTailwindSpacing && options.tailwindConfigPath) {
|
|
387
423
|
tokenMap = mergeTokenMapFromTailwindSpacing({
|
|
388
424
|
currentMap: tokenMap,
|
|
425
|
+
origins,
|
|
389
426
|
tailwindConfigPath: options.tailwindConfigPath,
|
|
390
427
|
});
|
|
391
428
|
}
|
|
@@ -456,15 +456,67 @@ function parseScssDeclarations(source) {
|
|
|
456
456
|
}
|
|
457
457
|
|
|
458
458
|
startPattern.lastIndex = index;
|
|
459
|
+
const isDefault = /!default\b/.test(value);
|
|
459
460
|
const cleaned = value.replace(/!(default|global)\b/g, '').trim();
|
|
460
|
-
|
|
461
|
+
// Sass semantics at the top level: `!default` assigns only when the variable is
|
|
462
|
+
// still unset, a plain assignment always wins. Bootstrap v6 declares
|
|
463
|
+
// `$spacers: () !default;` and then `$spacers: defaults((...), $spacers);`.
|
|
464
|
+
if (cleaned && (!declarations.has(name) || !isDefault)) {
|
|
461
465
|
declarations.set(name, cleaned);
|
|
462
466
|
}
|
|
463
467
|
}
|
|
464
468
|
|
|
469
|
+
for (const [name, raw] of declarations) {
|
|
470
|
+
const unwrapped = unwrapScssMapCall(raw, declarations);
|
|
471
|
+
if (unwrapped) {
|
|
472
|
+
declarations.set(name, unwrapped);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
465
476
|
return declarations;
|
|
466
477
|
}
|
|
467
478
|
|
|
479
|
+
/**
|
|
480
|
+
* A map declared through a merge helper, read as the map it produces:
|
|
481
|
+
* `defaults((...), $spacers)` (Bootstrap v6), `map.merge($a, (...))`,
|
|
482
|
+
* `map-merge((...), (...))`. Map-literal arguments and variables that hold a
|
|
483
|
+
* map are merged left to right with later keys winning, as Sass does; anything
|
|
484
|
+
* else (an unset `!default` placeholder, an expression) contributes nothing.
|
|
485
|
+
*/
|
|
486
|
+
const SCSS_MAP_MERGE_CALL = /^(?:defaults|map\.merge|map-merge)\s*\(([\s\S]*)\)$/;
|
|
487
|
+
|
|
488
|
+
function unwrapScssMapCall(raw, declarations) {
|
|
489
|
+
const match = raw.match(SCSS_MAP_MERGE_CALL);
|
|
490
|
+
if (!match) {
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
const entries = new Map();
|
|
494
|
+
for (const argument of splitTopLevel(match[1], ',')) {
|
|
495
|
+
const text = argument.trim();
|
|
496
|
+
let mapLiteral = null;
|
|
497
|
+
if (isScssMap(text)) {
|
|
498
|
+
mapLiteral = text;
|
|
499
|
+
} else if (/^\$[\w-]+$/.test(text)) {
|
|
500
|
+
const referenced = declarations.get(text.slice(1));
|
|
501
|
+
if (referenced && isScssMap(referenced)) {
|
|
502
|
+
mapLiteral = referenced;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (!mapLiteral) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
for (const entry of splitTopLevel(mapLiteral.slice(1, -1), ',')) {
|
|
509
|
+
const pair = splitTopLevel(entry, ':');
|
|
510
|
+
if (pair.length < 2) continue;
|
|
511
|
+
entries.set(pair[0].trim(), pair.slice(1).join(':').trim());
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
if (entries.size === 0) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return `(${[...entries].map(([key, expression]) => `${key}: ${expression}`).join(', ')})`;
|
|
518
|
+
}
|
|
519
|
+
|
|
468
520
|
function isScssMap(raw) {
|
|
469
521
|
return raw.startsWith('(') && raw.endsWith(')') && /:/.test(raw);
|
|
470
522
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
|
|
3
4
|
const { formatTime } = require('../../core/time');
|
|
4
5
|
const { createTailwindMotionAnalyzer } = require('../../core/tailwind-motion-analysis');
|
|
5
6
|
|
|
@@ -51,7 +52,7 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
51
52
|
? formatTime(analysis.nearest.upper, 'ms')
|
|
52
53
|
: 'n/a';
|
|
53
54
|
context.report({
|
|
54
|
-
message: buildMessage(analysis, segment, lower, upper),
|
|
55
|
+
message: withNote(buildMessage(analysis, segment, lower, upper), analyzer.note),
|
|
55
56
|
node,
|
|
56
57
|
fix:
|
|
57
58
|
fixedText && analysis.reason === 'duration'
|
|
@@ -95,13 +96,16 @@ module.exports = {
|
|
|
95
96
|
},
|
|
96
97
|
type: 'array',
|
|
97
98
|
},
|
|
99
|
+
note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
|
|
98
100
|
},
|
|
99
101
|
type: 'object',
|
|
100
102
|
},
|
|
101
103
|
],
|
|
102
104
|
},
|
|
103
105
|
create(context) {
|
|
104
|
-
const
|
|
106
|
+
const option = (context.options && context.options[0]) || {};
|
|
107
|
+
const analyzer = createTailwindMotionAnalyzer(option);
|
|
108
|
+
analyzer.note = normalizeNote(option.note);
|
|
105
109
|
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
106
110
|
|
|
107
111
|
return {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const {
|
|
4
|
-
const { createTailwindClassAnalyzer } = require('../../core/tailwind-class-analysis');
|
|
3
|
+
const { MAX_NOTE_LENGTH, normalizeNote, withNote } = require('../../core/options');
|
|
4
|
+
const { createTailwindClassAnalyzer, offScaleClassMessage } = require('../../core/tailwind-class-analysis');
|
|
5
5
|
|
|
6
6
|
const RULE_NAME = 'tailwind-class-use-scale';
|
|
7
7
|
|
|
@@ -44,17 +44,8 @@ function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
|
44
44
|
: null;
|
|
45
45
|
|
|
46
46
|
for (const { analysis, segment } of findings) {
|
|
47
|
-
const lower = analysis.nearest
|
|
48
|
-
? formatLength(analysis.nearest.lower, 'px')
|
|
49
|
-
: 'n/a';
|
|
50
|
-
const upper = analysis.nearest
|
|
51
|
-
? formatLength(analysis.nearest.upper, 'px')
|
|
52
|
-
: 'n/a';
|
|
53
47
|
context.report({
|
|
54
|
-
message:
|
|
55
|
-
analysis.reason === 'negative'
|
|
56
|
-
? `Unexpected Tailwind arbitrary spacing value "${segment.token}". Negative values are disabled for this rule.`
|
|
57
|
-
: `Unexpected Tailwind arbitrary spacing value "${segment.token}". Use scale values (nearest: ${lower} or ${upper}).`,
|
|
48
|
+
message: withNote(offScaleClassMessage(segment.token, analysis), analyzer.note),
|
|
58
49
|
node,
|
|
59
50
|
fix:
|
|
60
51
|
fixedText && analysis.reason !== 'negative'
|
|
@@ -77,6 +68,7 @@ module.exports = {
|
|
|
77
68
|
properties: {
|
|
78
69
|
allowNegative: { type: 'boolean' },
|
|
79
70
|
baseFontSize: { type: 'number' },
|
|
71
|
+
note: { maxLength: MAX_NOTE_LENGTH, minLength: 1, type: 'string' },
|
|
80
72
|
scale: {
|
|
81
73
|
items: {
|
|
82
74
|
anyOf: [
|
|
@@ -86,6 +78,12 @@ module.exports = {
|
|
|
86
78
|
},
|
|
87
79
|
type: 'array',
|
|
88
80
|
},
|
|
81
|
+
spacingUnit: {
|
|
82
|
+
anyOf: [
|
|
83
|
+
{ exclusiveMinimum: true, minimum: 0, type: 'number' },
|
|
84
|
+
{ enum: [false] },
|
|
85
|
+
],
|
|
86
|
+
},
|
|
89
87
|
units: {
|
|
90
88
|
items: { type: 'string' },
|
|
91
89
|
type: 'array',
|
|
@@ -96,7 +94,9 @@ module.exports = {
|
|
|
96
94
|
],
|
|
97
95
|
},
|
|
98
96
|
create(context) {
|
|
99
|
-
const
|
|
97
|
+
const option = (context.options && context.options[0]) || {};
|
|
98
|
+
const analyzer = createTailwindClassAnalyzer(option);
|
|
99
|
+
analyzer.note = normalizeNote(option.note);
|
|
100
100
|
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
101
101
|
|
|
102
102
|
return {
|
|
@@ -30,14 +30,14 @@ const { validatePrimary, validateNoOffscaleTransformSecondaryOptions } = require
|
|
|
30
30
|
|
|
31
31
|
const { reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
|
|
32
32
|
const { decisionFor, loadRcDecisions } = require('../../core/decisions');
|
|
33
|
-
const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
|
|
33
|
+
const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
|
|
34
34
|
|
|
35
35
|
const ruleName = 'rhythmguard/no-offscale-transform';
|
|
36
36
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
37
37
|
invalidPreset: (presetName, presetNames) =>
|
|
38
38
|
`Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
|
|
39
|
-
rejected: (value, lower, upper) =>
|
|
40
|
-
`Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper})
|
|
39
|
+
rejected: (value, lower, upper, note = '') =>
|
|
40
|
+
`Unexpected transform translation value "${value}". Use scale values (nearest: ${lower} or ${upper}).${note ? ` ${note}` : ''}`,
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
const ruleFunction = (primary, secondaryOptions) => {
|
|
@@ -93,6 +93,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
93
93
|
node.value,
|
|
94
94
|
formatLength(nearest.lower, nearestUnit),
|
|
95
95
|
formatLength(nearest.upper, nearestUnit),
|
|
96
|
+
[tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)), options.note].filter(Boolean).join(' '),
|
|
96
97
|
),
|
|
97
98
|
node,
|
|
98
99
|
replacement: fixedValue,
|
|
@@ -38,22 +38,27 @@ const ruleName = 'rhythmguard/prefer-token';
|
|
|
38
38
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
39
39
|
invalidPreset: (presetName, presetNames) =>
|
|
40
40
|
`Unknown scale preset "${presetName}". Available presets: ${presetNames.join(', ')}.`,
|
|
41
|
-
rejected: (value) =>
|
|
42
|
-
`Unexpected raw scale value "${value}".
|
|
41
|
+
rejected: (value, replacement = null, origin = null, note = '') =>
|
|
42
|
+
`Unexpected raw scale value "${value}". ${replacement
|
|
43
|
+
? `Use ${replacement}${origin ? ` (${origin})` : ''}.`
|
|
44
|
+
: 'No known token holds this value; use the nearest token or add one.'}${note ? ` ${note}` : ''}`,
|
|
43
45
|
});
|
|
44
46
|
|
|
45
47
|
function applyNegativeToken(replacement, parsedLength) {
|
|
46
48
|
return !replacement || parsedLength.number >= 0 ? replacement : negateReplacement(replacement);
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
/** The token for a raw length, and the text the fix writes (negated when the literal is). */
|
|
49
52
|
function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
|
|
53
|
+
const found = (token) => ({ replacement: applyNegativeToken(token, parsedLength), token });
|
|
54
|
+
|
|
50
55
|
if (Object.prototype.hasOwnProperty.call(tokenMap, raw)) {
|
|
51
|
-
return
|
|
56
|
+
return found(tokenMap[raw]);
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
const absoluteRaw = formatLength(Math.abs(parsedLength.number), parsedLength.unit || 'px');
|
|
55
60
|
if (Object.prototype.hasOwnProperty.call(tokenMap, absoluteRaw)) {
|
|
56
|
-
return
|
|
61
|
+
return found(tokenMap[absoluteRaw]);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
if (options.unitStrategy === 'convert') {
|
|
@@ -61,7 +66,7 @@ function resolveTokenReplacement(tokenMap, raw, parsedLength, options) {
|
|
|
61
66
|
if (absPx !== null) {
|
|
62
67
|
const pxKey = `${absPx}px`;
|
|
63
68
|
if (Object.prototype.hasOwnProperty.call(tokenMap, pxKey)) {
|
|
64
|
-
return
|
|
69
|
+
return found(tokenMap[pxKey]);
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
}
|
|
@@ -92,8 +97,10 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
92
97
|
withResolvedScale(options, root);
|
|
93
98
|
|
|
94
99
|
const tokenRegex = createTokenRegex(options.tokenPattern, result, ruleName);
|
|
100
|
+
const origins = {};
|
|
95
101
|
const tokenMap = buildEffectiveTokenMap({
|
|
96
102
|
options,
|
|
103
|
+
origins,
|
|
97
104
|
root,
|
|
98
105
|
tokenRegex,
|
|
99
106
|
});
|
|
@@ -114,8 +121,10 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
114
121
|
const { scaleByUnit, scalePx } = getScaleStateForProperty(prop);
|
|
115
122
|
let changed = false;
|
|
116
123
|
|
|
117
|
-
const reportNode = (node,
|
|
118
|
-
|
|
124
|
+
const reportNode = (node, resolved = null) => {
|
|
125
|
+
const replacement = resolved ? resolved.replacement : null;
|
|
126
|
+
const origin = resolved ? origins[resolved.token] || null : null;
|
|
127
|
+
reportValueNode({ decl, message: messages.rejected(node.value, replacement, origin, options.note), node, replacement, result, ruleName });
|
|
119
128
|
};
|
|
120
129
|
|
|
121
130
|
const checkWordNode = (node, context) => {
|
|
@@ -181,9 +190,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
181
190
|
}
|
|
182
191
|
}
|
|
183
192
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
reportNode(node, replacement);
|
|
193
|
+
reportNode(node, resolveTokenReplacement(tokenMap, node.value, parsedLength, options));
|
|
187
194
|
return true;
|
|
188
195
|
};
|
|
189
196
|
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
|
|
21
21
|
const { reportValueNode } = require('../report');
|
|
22
22
|
const { validatePrimary } = require('../validate');
|
|
23
|
+
const { isNote, normalizeNote, withNote } = require('../../core/options');
|
|
23
24
|
|
|
24
25
|
const ruleName = 'rhythmguard/use-motion-scale';
|
|
25
26
|
const DURATION_PROPERTIES = new Set([
|
|
@@ -39,12 +40,12 @@ const EASING_PROPERTIES = new Set([
|
|
|
39
40
|
const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
|
|
40
41
|
|
|
41
42
|
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
42
|
-
invalidDuration: (value) =>
|
|
43
|
-
`Unexpected negative motion duration "${value}". Use non-negative duration values.`,
|
|
44
|
-
rejectedDuration: (value, lower, upper) =>
|
|
45
|
-
`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`,
|
|
46
|
-
rejectedEasing: (value) =>
|
|
47
|
-
`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`,
|
|
43
|
+
invalidDuration: (value, note = '') =>
|
|
44
|
+
withNote(`Unexpected negative motion duration "${value}". Use non-negative duration values.`, note),
|
|
45
|
+
rejectedDuration: (value, lower, upper, note = '') =>
|
|
46
|
+
withNote(`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`, note),
|
|
47
|
+
rejectedEasing: (value, note = '') =>
|
|
48
|
+
withNote(`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`, note),
|
|
48
49
|
});
|
|
49
50
|
|
|
50
51
|
function isPlainObject(value) {
|
|
@@ -59,6 +60,7 @@ function buildOptions(rawOptions) {
|
|
|
59
60
|
durationUnits: normalizeDurationUnits(options.durationUnits),
|
|
60
61
|
easingTokenMap: isPlainObject(options.easingTokenMap) ? options.easingTokenMap : {},
|
|
61
62
|
fixToScale: options.fixToScale !== false,
|
|
63
|
+
note: normalizeNote(options.note),
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -78,6 +80,7 @@ function validateSecondaryOptions(result, secondaryOptions) {
|
|
|
78
80
|
typeof entry === 'string' && entry.trim().length > 0,
|
|
79
81
|
)],
|
|
80
82
|
fixToScale: [true, false],
|
|
83
|
+
note: [isNote],
|
|
81
84
|
},
|
|
82
85
|
});
|
|
83
86
|
}
|
|
@@ -124,8 +127,9 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
124
127
|
node.value,
|
|
125
128
|
formatTime(nearest.lower, 'ms'),
|
|
126
129
|
formatTime(nearest.upper, 'ms'),
|
|
130
|
+
options.note,
|
|
127
131
|
)
|
|
128
|
-
: messages.invalidDuration(node.value),
|
|
132
|
+
: messages.invalidDuration(node.value, options.note),
|
|
129
133
|
node,
|
|
130
134
|
replacement: fixedValue,
|
|
131
135
|
result,
|
|
@@ -146,7 +150,7 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
146
150
|
}
|
|
147
151
|
: null,
|
|
148
152
|
length: source.length,
|
|
149
|
-
message: messages.rejectedEasing(source),
|
|
153
|
+
message: messages.rejectedEasing(source, options.note),
|
|
150
154
|
node,
|
|
151
155
|
result,
|
|
152
156
|
ruleName,
|
|
@@ -32,7 +32,7 @@ const {
|
|
|
32
32
|
|
|
33
33
|
const { createTokenRegex, reportInvalidPreset, reportProblem, reportValueNode } = require('../report');
|
|
34
34
|
const { decisionFor, loadRcDecisions } = require('../../core/decisions');
|
|
35
|
-
const { replacementFor, tokenIndexFromDefinitions } = require('../../core/token-index');
|
|
35
|
+
const { replacementFor, tokenHoldsNote, tokenIndexFromDefinitions } = require('../../core/token-index');
|
|
36
36
|
const { validatePrimary, validateUseScaleSecondaryOptions } = require('../validate');
|
|
37
37
|
|
|
38
38
|
const ruleName = 'rhythmguard/use-scale';
|
|
@@ -198,9 +198,10 @@ const ruleFunction = (primary, secondaryOptions) => {
|
|
|
198
198
|
const report = (value, decl, node, nearest, fixedValue = null, nearestUnit = 'px') => {
|
|
199
199
|
const lower = nearest ? formatLength(nearest.lower, nearestUnit) : 'n/a';
|
|
200
200
|
const upper = nearest ? formatLength(nearest.upper, nearestUnit) : 'n/a';
|
|
201
|
+
const tokenNote = nearest ? tokenHoldsNote(fixedValue, formatLength(nearest.nearest, nearestUnit)) : '';
|
|
201
202
|
reportValueNode({
|
|
202
203
|
decl,
|
|
203
|
-
message: messages.rejected(value, lower, upper, fallbackNote),
|
|
204
|
+
message: messages.rejected(value, lower, upper, [fallbackNote, tokenNote, options.note].filter(Boolean).join(' ')),
|
|
204
205
|
node,
|
|
205
206
|
replacement: fixedValue,
|
|
206
207
|
result,
|
package/types/shared.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export interface RhythmguardRuleOptions {
|
|
|
21
21
|
fixWith?: "value" | "token";
|
|
22
22
|
/** Read the `decisions` section of `.rhythmguardrc.json` (default true). The audit sets false and applies decisions itself. */
|
|
23
23
|
decisions?: boolean;
|
|
24
|
+
/** A sentence of up to 200 characters appended to every finding, after the built-in text. */
|
|
25
|
+
note?: string;
|
|
24
26
|
baseFontSize?: number;
|
|
25
27
|
customScale?: ScaleValue[];
|
|
26
28
|
includeMathFunctions?: boolean;
|