stylelint-plugin-rhythmguard 1.8.0 → 1.9.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 +9 -0
- package/README.md +44 -1
- package/package.json +9 -1
- package/src/cli/audit.js +192 -26
- package/src/configs/motion.js +13 -0
- package/src/configs/motion.mjs +4 -0
- package/src/eslint/index.js +3 -0
- package/src/eslint/rules/tailwind-class-use-motion-scale.js +116 -0
- package/src/index.js +4 -1
- package/src/rules/use-motion-scale/index.js +245 -0
- package/src/rules/use-motion-scale/index.mjs +7 -0
- package/src/utils/constants.js +4 -0
- package/src/utils/tailwind-motion-analysis.js +174 -0
- package/src/utils/time.js +89 -0
- package/src/utils/token-sources.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ The format follows Keep a Changelog principles and semantic versioning.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.9.0] - 2026-05-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Added opt-in `rhythmguard/use-motion-scale` for duration/delay scale enforcement and raw easing reporting.
|
|
14
|
+
- Added `stylelint-plugin-rhythmguard/configs/motion`.
|
|
15
|
+
- Added ESLint companion rule `rhythmguard-tailwind/tailwind-class-use-motion-scale` for Tailwind `duration-[...]`, `delay-[...]`, and `ease-[...]` arbitrary values.
|
|
16
|
+
- Added `rhythmguard audit --include-motion` and `.rhythmguardrc.json` `includeMotion` support.
|
|
17
|
+
|
|
9
18
|
## [1.8.0] - 2026-05-23
|
|
10
19
|
|
|
11
20
|
### Added
|
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ This gives you spacing governance in both CSS files and JSX/TSX templates.
|
|
|
66
66
|
| `rhythmguard/use-scale` | Enforces spacing values must be on your configured scale | Yes, nearest safe value |
|
|
67
67
|
| `rhythmguard/prefer-token` | Enforces token usage over raw spacing literals | Yes, with `tokenMap` |
|
|
68
68
|
| `rhythmguard/no-offscale-transform` | Enforces scale-aligned `translate*` motion offsets | Yes, nearest safe value |
|
|
69
|
+
| `rhythmguard/use-motion-scale` | Enforces opt-in duration/delay rhythm and flags raw easing curves | Yes, for duration/delay values |
|
|
69
70
|
|
|
70
71
|
## Demo
|
|
71
72
|
|
|
@@ -95,6 +96,7 @@ npx rhythmguard audit ./src --since-baseline --fail-on-new-drift
|
|
|
95
96
|
npx rhythmguard audit ./src --staged --max-findings 0
|
|
96
97
|
npx rhythmguard audit ./src --token-source ./tokens.json
|
|
97
98
|
npx rhythmguard audit ./src --token-source ./theme.css --token-source-format css
|
|
99
|
+
npx rhythmguard audit ./src --include-motion
|
|
98
100
|
```
|
|
99
101
|
|
|
100
102
|
The report covers authored CSS declarations, Tailwind arbitrary spacing values in common template/source files, and token-contract drift such as missing spacing tokens, unused spacing tokens, repeated raw values that deserve token review, raw values that match known tokens, and conflicting token values. Scan paths are scoped to the directory argument. Use `--ignore`, `.rhythmguardignore`, or `--ignore-path` for generated or legacy subtrees, then add baselines and CI thresholds when you are ready to gate new drift. Markdown output is PR-ready for UX developers, UX designers, and design-system owners:
|
|
@@ -125,6 +127,7 @@ For large codebases, put shared audit settings in `.rhythmguardrc.json`:
|
|
|
125
127
|
{ "path": "./src/theme.css", "format": "css" }
|
|
126
128
|
],
|
|
127
129
|
"tokenKind": "spacing",
|
|
130
|
+
"includeMotion": false,
|
|
128
131
|
"tokenCandidateMinCount": 2,
|
|
129
132
|
"minCleanliness": 90
|
|
130
133
|
}
|
|
@@ -221,6 +224,16 @@ npm install --save-dev stylelint-plugin-rhythmguard
|
|
|
221
224
|
|
|
222
225
|
`react-tailwind` extends the tailwind config with CSS Modules overrides (spacing + radius enforcement) and ignores Next.js build directories.
|
|
223
226
|
|
|
227
|
+
### Motion config
|
|
228
|
+
|
|
229
|
+
```json
|
|
230
|
+
{
|
|
231
|
+
"extends": ["stylelint-plugin-rhythmguard/configs/motion"]
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
`motion` enables opt-in duration/delay rhythm checks with `rhythmguard/use-motion-scale`.
|
|
236
|
+
|
|
224
237
|
Stable shared config entry points:
|
|
225
238
|
|
|
226
239
|
- `stylelint-plugin-rhythmguard/configs/recommended`
|
|
@@ -230,6 +243,7 @@ Stable shared config entry points:
|
|
|
230
243
|
- `stylelint-plugin-rhythmguard/configs/expanded`
|
|
231
244
|
- `stylelint-plugin-rhythmguard/configs/logical`
|
|
232
245
|
- `stylelint-plugin-rhythmguard/configs/migration`
|
|
246
|
+
- `stylelint-plugin-rhythmguard/configs/motion`
|
|
233
247
|
|
|
234
248
|
Framework-specific setup for Vue, Lit, Astro, and SvelteKit: [`docs/FRAMEWORKS.md`](https://github.com/PetriLahdelma/stylelint-plugin-rhythmguard/blob/main/docs/FRAMEWORKS.md)
|
|
235
249
|
|
|
@@ -471,7 +485,7 @@ Options:
|
|
|
471
485
|
| `enforceInsideMathFunctions` | `boolean` | `false` | Lints `calc()/clamp()/min()/max()` internals |
|
|
472
486
|
| `mathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Restricts linting to specific 1-based argument indexes per math function |
|
|
473
487
|
| `ignoreMathFunctionArguments` | `Record<mathFn, number[]>` | `{}` | Excludes specific 1-based argument indexes per math function |
|
|
474
|
-
| `propertyGroups` | `Array<'spacing' \| 'radius' \| 'typography' \| 'size'>` | `['spacing']` | Selects built-in property groups when `properties` is not provided |
|
|
488
|
+
| `propertyGroups` | `Array<'spacing' \| 'radius' \| 'typography' \| 'size' \| 'motion'>` | `['spacing']` | Selects built-in property groups when `properties` is not provided |
|
|
475
489
|
| `properties` | `Array<string|RegExp>` | built-in spacing patterns | Override targeted property set; string values may be supported property names or regex-like strings (`/pattern/flags`) |
|
|
476
490
|
| `propertyScales` | `Record<propertyOrRegex, scaleOrPreset>` | `{}` | Per-property scale overrides (supports exact names or `/regex/flags` keys; stateful `g`/`y` flags are normalized for deterministic matching) |
|
|
477
491
|
|
|
@@ -543,6 +557,35 @@ Options:
|
|
|
543
557
|
|
|
544
558
|
`rhythmguard/no-offscale-transform` accepts the same scale options as `rhythmguard/use-scale` (including `unitStrategy`, math argument targeting, and deterministic autofix), but only for transform translation properties. Its secondary options are also validated for unknown keys and invalid value shapes.
|
|
545
559
|
|
|
560
|
+
### `rhythmguard/use-motion-scale`
|
|
561
|
+
|
|
562
|
+
Opt-in guardrail for duration, delay, and easing rhythm.
|
|
563
|
+
|
|
564
|
+
Example:
|
|
565
|
+
|
|
566
|
+
```css
|
|
567
|
+
/* ❌ Off-scale timing + raw easing */
|
|
568
|
+
.button {
|
|
569
|
+
transition: opacity 175ms cubic-bezier(.2, 0, 0, 1);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/* ✅ Timing on motion scale */
|
|
573
|
+
.button {
|
|
574
|
+
transition: opacity 150ms var(--ease-snappy);
|
|
575
|
+
}
|
|
576
|
+
```
|
|
577
|
+
|
|
578
|
+
Options:
|
|
579
|
+
|
|
580
|
+
| Option | Type | Default | Description |
|
|
581
|
+
| --- | --- | --- | --- |
|
|
582
|
+
| `durationScale` | `number[]` | `[0,75,100,150,200,300,500,700,1000]` | Allowed duration and delay values in milliseconds |
|
|
583
|
+
| `durationUnits` | `Array<'ms' \| 's'>` | `['ms','s']` | Time units considered by the rule |
|
|
584
|
+
| `fixToScale` | `boolean` | `true` | Autofixes simple duration/delay values to the nearest scale value |
|
|
585
|
+
| `easingTokenMap` | `Record<string,string>` | `{}` | Optional exact replacements for raw easing functions |
|
|
586
|
+
|
|
587
|
+
Tailwind class strings can use the ESLint companion rule `rhythmguard-tailwind/tailwind-class-use-motion-scale` for `duration-[...]`, `delay-[...]`, and `ease-[...]` arbitrary values.
|
|
588
|
+
|
|
546
589
|
## Tailwind CSS Integration
|
|
547
590
|
|
|
548
591
|
Rhythmguard works well in Tailwind projects, but it enforces what Stylelint can parse: CSS declarations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stylelint-plugin-rhythmguard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
4
4
|
"description": "Token governance for CSS and Tailwind — enforce spacing scales, require design tokens, catch arbitrary values",
|
|
5
5
|
"bin": {
|
|
6
6
|
"rhythmguard": "src/cli/index.js"
|
|
@@ -49,6 +49,10 @@
|
|
|
49
49
|
"require": "./src/configs/migration.js",
|
|
50
50
|
"import": "./src/configs/migration.mjs"
|
|
51
51
|
},
|
|
52
|
+
"./configs/motion": {
|
|
53
|
+
"require": "./src/configs/motion.js",
|
|
54
|
+
"import": "./src/configs/motion.mjs"
|
|
55
|
+
},
|
|
52
56
|
"./configs/react-tailwind": {
|
|
53
57
|
"require": "./src/configs/react-tailwind.js",
|
|
54
58
|
"import": "./src/configs/react-tailwind.mjs"
|
|
@@ -69,6 +73,10 @@
|
|
|
69
73
|
"require": "./src/rules/no-offscale-transform/index.js",
|
|
70
74
|
"import": "./src/rules/no-offscale-transform/index.mjs"
|
|
71
75
|
},
|
|
76
|
+
"./rules/use-motion-scale": {
|
|
77
|
+
"require": "./src/rules/use-motion-scale/index.js",
|
|
78
|
+
"import": "./src/rules/use-motion-scale/index.mjs"
|
|
79
|
+
},
|
|
72
80
|
"./eslint": {
|
|
73
81
|
"require": "./src/eslint/index.js",
|
|
74
82
|
"import": "./src/eslint/index.mjs"
|
package/src/cli/audit.js
CHANGED
|
@@ -6,6 +6,8 @@ const path = require('node:path');
|
|
|
6
6
|
|
|
7
7
|
const { formatLength } = require('../utils/length');
|
|
8
8
|
const { createTailwindClassAnalyzer } = require('../utils/tailwind-class-analysis');
|
|
9
|
+
const { createTailwindMotionAnalyzer } = require('../utils/tailwind-motion-analysis');
|
|
10
|
+
const { formatTime } = require('../utils/time');
|
|
9
11
|
const {
|
|
10
12
|
addDefinition,
|
|
11
13
|
createTokenKindMatcher,
|
|
@@ -69,9 +71,10 @@ Options:
|
|
|
69
71
|
--min-cleanliness <percent> Exit 1 when scale cleanliness is lower than this percent
|
|
70
72
|
--since <git-ref> Scan only changed files since a git ref
|
|
71
73
|
--staged Scan only staged files
|
|
74
|
+
--include-motion Include opt-in motion duration/easing drift
|
|
72
75
|
--token-source <file> External token source (repeatable, comma-separated)
|
|
73
76
|
--token-source-format <format> Token source format: auto, css, flat-json, style-dictionary, dtcg (default: auto)
|
|
74
|
-
--token-kind <kind> Token kind: spacing, radius, typography, size, all (default: spacing)
|
|
77
|
+
--token-kind <kind> Token kind: spacing, radius, typography, size, motion, all (default: spacing)
|
|
75
78
|
--token-candidate-min-count <n> Minimum repeated raw value count for token candidates (default: 2)
|
|
76
79
|
--scale <values> Comma-separated scale values (default: 0,4,8,12,16,24,32)
|
|
77
80
|
--base-font-size <number> px base for rem/em conversion (default: 16)
|
|
@@ -89,6 +92,7 @@ function parseArgs(argv) {
|
|
|
89
92
|
format: 'text',
|
|
90
93
|
ignorePath: DEFAULT_IGNORE_PATH,
|
|
91
94
|
ignorePatterns: [],
|
|
95
|
+
includeMotion: false,
|
|
92
96
|
maxFindings: null,
|
|
93
97
|
minCleanliness: null,
|
|
94
98
|
noConfig: false,
|
|
@@ -261,6 +265,12 @@ function parseArgs(argv) {
|
|
|
261
265
|
continue;
|
|
262
266
|
}
|
|
263
267
|
|
|
268
|
+
if (arg === '--include-motion') {
|
|
269
|
+
parsed.includeMotion = true;
|
|
270
|
+
parsed.cliOptions.add('includeMotion');
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
264
274
|
if (arg === '--token-source') {
|
|
265
275
|
parsed.tokenSources.push(...parseTokenSourcePaths(argv[++index]));
|
|
266
276
|
parsed.cliOptions.add('tokenSources');
|
|
@@ -572,6 +582,7 @@ function applyAuditConfig(parsed, configResult) {
|
|
|
572
582
|
applyConfigScalar(next, audit, cliOptions, 'tokenCandidateMinCount', 'tokenCandidateMinCount', parsePositiveInteger);
|
|
573
583
|
applyConfigScalar(next, audit, cliOptions, 'tokenKind', 'tokenKind', normalizeTokenKind);
|
|
574
584
|
applyConfigScalar(next, audit, cliOptions, 'baseFontSize', 'baseFontSize', parseBaseFontSize);
|
|
585
|
+
applyConfigScalar(next, audit, cliOptions, 'includeMotion', 'includeMotion', parseBooleanOption);
|
|
575
586
|
applyConfigScalar(next, audit, cliOptions, 'scale', 'scale', (value) => {
|
|
576
587
|
if (Array.isArray(value)) {
|
|
577
588
|
return value;
|
|
@@ -591,6 +602,14 @@ function applyConfigScalar(target, audit, cliOptions, targetKey, configKey, pars
|
|
|
591
602
|
target[targetKey] = parser(audit[configKey], configKey);
|
|
592
603
|
}
|
|
593
604
|
|
|
605
|
+
function parseBooleanOption(value, optionName) {
|
|
606
|
+
if (typeof value !== 'boolean') {
|
|
607
|
+
throw new Error(`${optionName} must be a boolean.`);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return value;
|
|
611
|
+
}
|
|
612
|
+
|
|
594
613
|
function normalizeCliTokenSources(sources, format) {
|
|
595
614
|
return sources.map((sourcePath) => ({
|
|
596
615
|
baseDir: process.cwd(),
|
|
@@ -866,31 +885,41 @@ async function runStylelintAudit(cssFiles, options) {
|
|
|
866
885
|
}
|
|
867
886
|
|
|
868
887
|
const { default: stylelint } = await import('stylelint');
|
|
888
|
+
const rules = {
|
|
889
|
+
'rhythmguard/use-scale': [
|
|
890
|
+
true,
|
|
891
|
+
{
|
|
892
|
+
baseFontSize: options.baseFontSize,
|
|
893
|
+
scale: options.scale,
|
|
894
|
+
severity: 'warning',
|
|
895
|
+
},
|
|
896
|
+
],
|
|
897
|
+
'rhythmguard/prefer-token': [
|
|
898
|
+
true,
|
|
899
|
+
{
|
|
900
|
+
baseFontSize: options.baseFontSize,
|
|
901
|
+
scale: options.scale,
|
|
902
|
+
severity: 'warning',
|
|
903
|
+
tokenMapFromCssCustomProperties: true,
|
|
904
|
+
tokenPattern: '^--spac(e|ing)-',
|
|
905
|
+
},
|
|
906
|
+
],
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
if (options.includeMotion) {
|
|
910
|
+
rules['rhythmguard/use-motion-scale'] = [
|
|
911
|
+
true,
|
|
912
|
+
{
|
|
913
|
+
severity: 'warning',
|
|
914
|
+
},
|
|
915
|
+
];
|
|
916
|
+
}
|
|
869
917
|
|
|
870
918
|
const result = await stylelint.lint({
|
|
871
919
|
files: cssFiles,
|
|
872
920
|
config: {
|
|
873
921
|
plugins: [pluginPath],
|
|
874
|
-
rules
|
|
875
|
-
'rhythmguard/use-scale': [
|
|
876
|
-
true,
|
|
877
|
-
{
|
|
878
|
-
baseFontSize: options.baseFontSize,
|
|
879
|
-
scale: options.scale,
|
|
880
|
-
severity: 'warning',
|
|
881
|
-
},
|
|
882
|
-
],
|
|
883
|
-
'rhythmguard/prefer-token': [
|
|
884
|
-
true,
|
|
885
|
-
{
|
|
886
|
-
baseFontSize: options.baseFontSize,
|
|
887
|
-
scale: options.scale,
|
|
888
|
-
severity: 'warning',
|
|
889
|
-
tokenMapFromCssCustomProperties: true,
|
|
890
|
-
tokenPattern: '^--spac(e|ing)-',
|
|
891
|
-
},
|
|
892
|
-
],
|
|
893
|
-
},
|
|
922
|
+
rules,
|
|
894
923
|
},
|
|
895
924
|
});
|
|
896
925
|
|
|
@@ -909,6 +938,12 @@ function collectCssFindings(fileResults) {
|
|
|
909
938
|
const tokenMatch = text.match(
|
|
910
939
|
/Unexpected raw scale value "([^"]+)"/,
|
|
911
940
|
);
|
|
941
|
+
const motionDurationMatch = text.match(
|
|
942
|
+
/Unexpected (?:motion duration|negative motion duration) "([^"]+)"/,
|
|
943
|
+
);
|
|
944
|
+
const motionEasingMatch = text.match(
|
|
945
|
+
/Unexpected raw motion easing "([^"]+)"/,
|
|
946
|
+
);
|
|
912
947
|
|
|
913
948
|
findings.push({
|
|
914
949
|
column: warning.column || 1,
|
|
@@ -916,8 +951,13 @@ function collectCssFindings(fileResults) {
|
|
|
916
951
|
line: warning.line || 1,
|
|
917
952
|
rule: warning.rule || 'rhythmguard',
|
|
918
953
|
text,
|
|
919
|
-
type:
|
|
920
|
-
value:
|
|
954
|
+
type: getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }),
|
|
955
|
+
value: getCssFindingValue({
|
|
956
|
+
motionDurationMatch,
|
|
957
|
+
motionEasingMatch,
|
|
958
|
+
offScaleMatch,
|
|
959
|
+
tokenMatch,
|
|
960
|
+
}),
|
|
921
961
|
});
|
|
922
962
|
}
|
|
923
963
|
}
|
|
@@ -925,6 +965,47 @@ function collectCssFindings(fileResults) {
|
|
|
925
965
|
return findings;
|
|
926
966
|
}
|
|
927
967
|
|
|
968
|
+
function getCssFindingValue({
|
|
969
|
+
motionDurationMatch,
|
|
970
|
+
motionEasingMatch,
|
|
971
|
+
offScaleMatch,
|
|
972
|
+
tokenMatch,
|
|
973
|
+
}) {
|
|
974
|
+
if (tokenMatch) {
|
|
975
|
+
return tokenMatch[1];
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
if (offScaleMatch) {
|
|
979
|
+
return offScaleMatch[1];
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
if (motionDurationMatch) {
|
|
983
|
+
return motionDurationMatch[1];
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
if (motionEasingMatch) {
|
|
987
|
+
return motionEasingMatch[1];
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function getCssFindingType({ motionDurationMatch, motionEasingMatch, tokenMatch }) {
|
|
994
|
+
if (motionDurationMatch) {
|
|
995
|
+
return 'motion-duration';
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (motionEasingMatch) {
|
|
999
|
+
return 'motion-easing';
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (tokenMatch) {
|
|
1003
|
+
return 'token-opportunity';
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
return 'off-scale';
|
|
1007
|
+
}
|
|
1008
|
+
|
|
928
1009
|
function collectTailwindFindings(templateFiles, options) {
|
|
929
1010
|
const analyzer = createTailwindClassAnalyzer(options);
|
|
930
1011
|
const findings = [];
|
|
@@ -969,6 +1050,66 @@ function collectTailwindFindings(templateFiles, options) {
|
|
|
969
1050
|
return findings;
|
|
970
1051
|
}
|
|
971
1052
|
|
|
1053
|
+
function collectTailwindMotionFindings(templateFiles, options) {
|
|
1054
|
+
if (!options.includeMotion) {
|
|
1055
|
+
return [];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
const analyzer = createTailwindMotionAnalyzer(options);
|
|
1059
|
+
const findings = [];
|
|
1060
|
+
|
|
1061
|
+
for (const filePath of templateFiles) {
|
|
1062
|
+
let source = '';
|
|
1063
|
+
try {
|
|
1064
|
+
source = fs.readFileSync(filePath, 'utf8');
|
|
1065
|
+
} catch {
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
const lineStarts = getLineStarts(source);
|
|
1070
|
+
|
|
1071
|
+
for (const literal of findStringLiterals(source)) {
|
|
1072
|
+
for (const { analysis, segment } of analyzer.analyzeClassString(literal.value)) {
|
|
1073
|
+
const position = offsetToLineColumn(lineStarts, literal.valueStart + segment.start);
|
|
1074
|
+
findings.push({
|
|
1075
|
+
column: position.column,
|
|
1076
|
+
file: formatPath(filePath),
|
|
1077
|
+
fixedToken: analysis.fixedToken,
|
|
1078
|
+
line: position.line,
|
|
1079
|
+
nearest: analysis.nearest
|
|
1080
|
+
? {
|
|
1081
|
+
lower: formatTime(analysis.nearest.lower, 'ms'),
|
|
1082
|
+
upper: formatTime(analysis.nearest.upper, 'ms'),
|
|
1083
|
+
}
|
|
1084
|
+
: null,
|
|
1085
|
+
rawValue: analysis.rawValue,
|
|
1086
|
+
rule: 'rhythmguard-tailwind/tailwind-class-use-motion-scale',
|
|
1087
|
+
text: buildTailwindMotionFindingText(segment.token, analysis),
|
|
1088
|
+
token: segment.token,
|
|
1089
|
+
type: analysis.reason === 'easing'
|
|
1090
|
+
? 'tailwind-motion-easing'
|
|
1091
|
+
: 'tailwind-motion-duration',
|
|
1092
|
+
utility: analysis.utility,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
return findings;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function buildTailwindMotionFindingText(token, analysis) {
|
|
1102
|
+
if (analysis.reason === 'easing') {
|
|
1103
|
+
return `Unexpected Tailwind arbitrary motion easing "${token}". Use motion tokens for easing decisions.`;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
if (analysis.reason === 'negative') {
|
|
1107
|
+
return `Unexpected Tailwind arbitrary motion duration "${token}". Use non-negative duration values.`;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
return `Unexpected Tailwind arbitrary motion duration "${token}". Use duration scale values.`;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
972
1113
|
function findStringLiterals(source) {
|
|
973
1114
|
const literals = [];
|
|
974
1115
|
const literalPattern = /(["'`])((?:\\[\s\S]|(?!\1)[\s\S])*?)\1/g;
|
|
@@ -1250,6 +1391,8 @@ function buildReport({
|
|
|
1250
1391
|
cssFindings,
|
|
1251
1392
|
dir,
|
|
1252
1393
|
externalTokenDefinitions,
|
|
1394
|
+
includeMotion,
|
|
1395
|
+
motionFindings,
|
|
1253
1396
|
scanScope,
|
|
1254
1397
|
templateFiles,
|
|
1255
1398
|
tailwindFindings,
|
|
@@ -1266,17 +1409,21 @@ function buildReport({
|
|
|
1266
1409
|
.map((finding) => finding.value));
|
|
1267
1410
|
const tailwindArbitraryValues = countByValue(tailwindFindings
|
|
1268
1411
|
.map((finding) => finding.rawValue));
|
|
1412
|
+
const motionValues = countByValue(motionFindings
|
|
1413
|
+
.map((finding) => finding.value || finding.rawValue));
|
|
1269
1414
|
const issueFiles = new Set([
|
|
1270
1415
|
...cssFindings.map((finding) => finding.file),
|
|
1416
|
+
...motionFindings.map((finding) => finding.file),
|
|
1271
1417
|
...tailwindFindings.map((finding) => finding.file),
|
|
1272
1418
|
]);
|
|
1273
1419
|
const topAffectedFiles = sortCountMap(countByValue([
|
|
1274
1420
|
...cssFindings.map((finding) => finding.file),
|
|
1421
|
+
...motionFindings.map((finding) => finding.file),
|
|
1275
1422
|
...tailwindFindings.map((finding) => finding.file),
|
|
1276
1423
|
])).slice(0, 10);
|
|
1277
1424
|
|
|
1278
1425
|
const totalFiles = cssFiles.length + templateFiles.length;
|
|
1279
|
-
const totalWarnings = cssFindings.length + tailwindFindings.length;
|
|
1426
|
+
const totalWarnings = cssFindings.length + motionFindings.length + tailwindFindings.length;
|
|
1280
1427
|
const filesWithIssues = issueFiles.size;
|
|
1281
1428
|
const scaleCleanliness = totalFiles > 0
|
|
1282
1429
|
? Math.max(0, Math.round(((totalFiles - filesWithIssues) / totalFiles) * 100))
|
|
@@ -1299,9 +1446,15 @@ function buildReport({
|
|
|
1299
1446
|
filesWithIssues,
|
|
1300
1447
|
findings: {
|
|
1301
1448
|
css: cssFindings,
|
|
1449
|
+
motion: motionFindings,
|
|
1302
1450
|
tailwind: tailwindFindings,
|
|
1303
1451
|
},
|
|
1304
|
-
formatVersion:
|
|
1452
|
+
formatVersion: 5,
|
|
1453
|
+
motion: {
|
|
1454
|
+
enabled: includeMotion,
|
|
1455
|
+
findings: motionFindings.length,
|
|
1456
|
+
values: Object.fromEntries(sortCountMap(motionValues).slice(0, 10)),
|
|
1457
|
+
},
|
|
1305
1458
|
offScaleValues: Object.fromEntries(sortCountMap(offScaleValues).slice(0, 10)),
|
|
1306
1459
|
scaleCleanliness,
|
|
1307
1460
|
scanScope,
|
|
@@ -1313,6 +1466,7 @@ function buildReport({
|
|
|
1313
1466
|
summary: {
|
|
1314
1467
|
cssWarnings: cssFindings.length,
|
|
1315
1468
|
filesWithIssues,
|
|
1469
|
+
motionFindings: motionFindings.length,
|
|
1316
1470
|
rawValueMatches: tokenContract.summary.rawValueMatches,
|
|
1317
1471
|
missingTokens: tokenContract.summary.missingTokens,
|
|
1318
1472
|
rawValueCandidates: tokenContract.summary.rawValueCandidates,
|
|
@@ -1405,6 +1559,7 @@ function writeBaseline(report, baselinePath) {
|
|
|
1405
1559
|
function getAllFindings(report) {
|
|
1406
1560
|
return [
|
|
1407
1561
|
...report.findings.css,
|
|
1562
|
+
...report.findings.motion,
|
|
1408
1563
|
...report.findings.tailwind,
|
|
1409
1564
|
];
|
|
1410
1565
|
}
|
|
@@ -1470,6 +1625,7 @@ function renderText(report) {
|
|
|
1470
1625
|
appendHistogram(lines, 'CSS OFF-SCALE VALUES', report.offScaleValues);
|
|
1471
1626
|
appendHistogram(lines, 'CSS TOKEN OPPORTUNITIES', report.tokenOpportunities);
|
|
1472
1627
|
appendHistogram(lines, 'TAILWIND CLASS-STRING DRIFT', report.tailwindArbitraryValues);
|
|
1628
|
+
appendHistogram(lines, 'MOTION RHYTHM DRIFT', report.motion.values);
|
|
1473
1629
|
appendTokenContractText(lines, report.tokenContract);
|
|
1474
1630
|
appendBaselineText(lines, report);
|
|
1475
1631
|
|
|
@@ -1622,6 +1778,7 @@ function renderMarkdown(report) {
|
|
|
1622
1778
|
appendMarkdownCounts(lines, 'CSS Off-Scale Values', report.offScaleValues);
|
|
1623
1779
|
appendMarkdownCounts(lines, 'CSS Token Opportunities', report.tokenOpportunities);
|
|
1624
1780
|
appendMarkdownCounts(lines, 'Tailwind Class-String Drift', report.tailwindArbitraryValues);
|
|
1781
|
+
appendMarkdownCounts(lines, 'Motion Rhythm Drift', report.motion.values);
|
|
1625
1782
|
appendTokenContractMarkdown(lines, report.tokenContract);
|
|
1626
1783
|
appendBaselineMarkdown(lines, report);
|
|
1627
1784
|
|
|
@@ -1872,6 +2029,7 @@ async function run() {
|
|
|
1872
2029
|
const { cssFiles, scanScope, templateFiles } = scanFiles;
|
|
1873
2030
|
const options = {
|
|
1874
2031
|
baseFontSize: parsed.baseFontSize,
|
|
2032
|
+
includeMotion: parsed.includeMotion,
|
|
1875
2033
|
scale: parsed.scale,
|
|
1876
2034
|
};
|
|
1877
2035
|
|
|
@@ -1888,14 +2046,22 @@ async function run() {
|
|
|
1888
2046
|
sources: parsed.tokenSources,
|
|
1889
2047
|
tokenKind: parsed.tokenKind,
|
|
1890
2048
|
});
|
|
2049
|
+
const stylelintFindings = collectCssFindings(cssResults);
|
|
2050
|
+
const cssFindings = stylelintFindings.filter((finding) => !finding.type.startsWith('motion-'));
|
|
2051
|
+
const motionFindings = [
|
|
2052
|
+
...stylelintFindings.filter((finding) => finding.type.startsWith('motion-')),
|
|
2053
|
+
...collectTailwindMotionFindings(templateFiles, options),
|
|
2054
|
+
];
|
|
1891
2055
|
|
|
1892
2056
|
const report = buildReport({
|
|
1893
2057
|
baseFontSize: parsed.baseFontSize,
|
|
1894
2058
|
config: parsed.config,
|
|
1895
2059
|
cssFiles,
|
|
1896
|
-
cssFindings
|
|
2060
|
+
cssFindings,
|
|
1897
2061
|
dir: parsed.dir,
|
|
1898
2062
|
externalTokenDefinitions: tokenSourceResult.definitions,
|
|
2063
|
+
includeMotion: parsed.includeMotion,
|
|
2064
|
+
motionFindings,
|
|
1899
2065
|
scanScope,
|
|
1900
2066
|
tailwindFindings: collectTailwindFindings(templateFiles, options),
|
|
1901
2067
|
templateFiles,
|
package/src/eslint/index.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const tailwindClassUseScale = require('./rules/tailwind-class-use-scale');
|
|
4
|
+
const tailwindClassUseMotionScale = require('./rules/tailwind-class-use-motion-scale');
|
|
4
5
|
|
|
5
6
|
module.exports = {
|
|
6
7
|
rules: {
|
|
7
8
|
'tailwind-class-use-scale': tailwindClassUseScale,
|
|
9
|
+
'tailwind-class-use-motion-scale': tailwindClassUseMotionScale,
|
|
8
10
|
},
|
|
9
11
|
configs: {
|
|
10
12
|
recommended: {
|
|
11
13
|
rules: {
|
|
12
14
|
'rhythmguard-tailwind/tailwind-class-use-scale': 'warn',
|
|
15
|
+
'rhythmguard-tailwind/tailwind-class-use-motion-scale': 'off',
|
|
13
16
|
},
|
|
14
17
|
},
|
|
15
18
|
},
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { formatTime } = require('../../utils/time');
|
|
4
|
+
const { createTailwindMotionAnalyzer } = require('../../utils/tailwind-motion-analysis');
|
|
5
|
+
|
|
6
|
+
const RULE_NAME = 'tailwind-class-use-motion-scale';
|
|
7
|
+
|
|
8
|
+
function maybeCheckNodeText(node, sourceCode, context, analyzer, allowFix) {
|
|
9
|
+
const rawText = sourceCode.getText(node);
|
|
10
|
+
let value = null;
|
|
11
|
+
let quote = null;
|
|
12
|
+
|
|
13
|
+
if (node.type === 'Literal' && typeof node.value === 'string') {
|
|
14
|
+
value = node.value;
|
|
15
|
+
quote = rawText[0] === '"' || rawText[0] === "'" ? rawText[0] : '"';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (node.type === 'TemplateElement') {
|
|
19
|
+
value = node.value.raw;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (!value || typeof value !== 'string') {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const findings = analyzer.analyzeClassString(value);
|
|
27
|
+
if (findings.length === 0) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let fixedValue = value;
|
|
32
|
+
let fixedValueOffset = 0;
|
|
33
|
+
|
|
34
|
+
for (const { analysis, segment } of findings) {
|
|
35
|
+
if (allowFix && analysis.reason === 'duration' && node.type === 'Literal') {
|
|
36
|
+
const replacementStart = segment.start + fixedValueOffset;
|
|
37
|
+
fixedValue = `${fixedValue.slice(0, replacementStart)}${analysis.fixedToken}${fixedValue.slice(replacementStart + segment.token.length)}`;
|
|
38
|
+
fixedValueOffset += analysis.fixedToken.length - segment.token.length;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fixedText = allowFix && node.type === 'Literal' && fixedValue !== value
|
|
43
|
+
? `${quote}${fixedValue.replace(new RegExp(quote, 'g'), `\\${quote}`)}${quote}`
|
|
44
|
+
: null;
|
|
45
|
+
|
|
46
|
+
for (const { analysis, segment } of findings) {
|
|
47
|
+
const lower = analysis.nearest
|
|
48
|
+
? formatTime(analysis.nearest.lower, 'ms')
|
|
49
|
+
: 'n/a';
|
|
50
|
+
const upper = analysis.nearest
|
|
51
|
+
? formatTime(analysis.nearest.upper, 'ms')
|
|
52
|
+
: 'n/a';
|
|
53
|
+
context.report({
|
|
54
|
+
message: buildMessage(analysis, segment, lower, upper),
|
|
55
|
+
node,
|
|
56
|
+
fix:
|
|
57
|
+
fixedText && analysis.reason === 'duration'
|
|
58
|
+
? (fixer) => fixer.replaceText(node, fixedText)
|
|
59
|
+
: null,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildMessage(analysis, segment, lower, upper) {
|
|
65
|
+
if (analysis.reason === 'easing') {
|
|
66
|
+
return `Unexpected Tailwind arbitrary motion easing "${segment.token}". Use motion tokens for easing decisions.`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (analysis.reason === 'negative') {
|
|
70
|
+
return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use non-negative duration values.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return `Unexpected Tailwind arbitrary motion duration "${segment.token}". Use duration scale values (nearest: ${lower} or ${upper}).`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = {
|
|
77
|
+
meta: {
|
|
78
|
+
docs: {
|
|
79
|
+
description: 'Enforce duration scale for Tailwind arbitrary motion utilities in class strings',
|
|
80
|
+
},
|
|
81
|
+
fixable: 'code',
|
|
82
|
+
schema: [
|
|
83
|
+
{
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
properties: {
|
|
86
|
+
durationScale: {
|
|
87
|
+
items: { type: 'number' },
|
|
88
|
+
type: 'array',
|
|
89
|
+
},
|
|
90
|
+
durationUnits: {
|
|
91
|
+
items: {
|
|
92
|
+
enum: ['ms', 's'],
|
|
93
|
+
type: 'string',
|
|
94
|
+
},
|
|
95
|
+
type: 'array',
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
type: 'object',
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
create(context) {
|
|
103
|
+
const analyzer = createTailwindMotionAnalyzer(context.options && context.options[0]);
|
|
104
|
+
const sourceCode = context.sourceCode || context.getSourceCode();
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
Literal(node) {
|
|
108
|
+
maybeCheckNodeText(node, sourceCode, context, analyzer, true);
|
|
109
|
+
},
|
|
110
|
+
TemplateElement(node) {
|
|
111
|
+
maybeCheckNodeText(node, sourceCode, context, analyzer, false);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
ruleName: RULE_NAME,
|
|
116
|
+
};
|
package/src/index.js
CHANGED
|
@@ -3,14 +3,16 @@
|
|
|
3
3
|
const useScale = require('./rules/use-scale');
|
|
4
4
|
const preferToken = require('./rules/prefer-token');
|
|
5
5
|
const noOffscaleTransform = require('./rules/no-offscale-transform');
|
|
6
|
+
const useMotionScale = require('./rules/use-motion-scale');
|
|
6
7
|
|
|
7
|
-
const rules = [useScale, preferToken, noOffscaleTransform];
|
|
8
|
+
const rules = [useScale, preferToken, noOffscaleTransform, useMotionScale];
|
|
8
9
|
|
|
9
10
|
module.exports = rules;
|
|
10
11
|
module.exports.rules = {
|
|
11
12
|
[useScale.ruleName]: useScale,
|
|
12
13
|
[preferToken.ruleName]: preferToken,
|
|
13
14
|
[noOffscaleTransform.ruleName]: noOffscaleTransform,
|
|
15
|
+
[useMotionScale.ruleName]: useMotionScale,
|
|
14
16
|
};
|
|
15
17
|
module.exports.configs = {
|
|
16
18
|
recommended: require('./configs/recommended'),
|
|
@@ -19,6 +21,7 @@ module.exports.configs = {
|
|
|
19
21
|
expanded: require('./configs/expanded'),
|
|
20
22
|
logical: require('./configs/logical'),
|
|
21
23
|
migration: require('./configs/migration'),
|
|
24
|
+
motion: require('./configs/motion'),
|
|
22
25
|
};
|
|
23
26
|
module.exports.eslint = require('./eslint');
|
|
24
27
|
module.exports.presets = require('./presets');
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const stylelint = require('stylelint');
|
|
4
|
+
const valueParser = require('postcss-value-parser');
|
|
5
|
+
const {
|
|
6
|
+
nearestScaleValues,
|
|
7
|
+
numbersEqual,
|
|
8
|
+
} = require('../../utils/length');
|
|
9
|
+
const {
|
|
10
|
+
declarationValueIndex,
|
|
11
|
+
walkRootValueNodes,
|
|
12
|
+
} = require('../../utils/value-utils');
|
|
13
|
+
const {
|
|
14
|
+
formatTime,
|
|
15
|
+
fromMs,
|
|
16
|
+
normalizeDurationScale,
|
|
17
|
+
normalizeDurationUnits,
|
|
18
|
+
parseTimeToken,
|
|
19
|
+
toMs,
|
|
20
|
+
} = require('../../utils/time');
|
|
21
|
+
|
|
22
|
+
const ruleName = 'rhythmguard/use-motion-scale';
|
|
23
|
+
const DURATION_PROPERTIES = new Set([
|
|
24
|
+
'transition-duration',
|
|
25
|
+
'transition-delay',
|
|
26
|
+
'animation-duration',
|
|
27
|
+
'animation-delay',
|
|
28
|
+
'transition',
|
|
29
|
+
'animation',
|
|
30
|
+
]);
|
|
31
|
+
const EASING_PROPERTIES = new Set([
|
|
32
|
+
'transition-timing-function',
|
|
33
|
+
'animation-timing-function',
|
|
34
|
+
'transition',
|
|
35
|
+
'animation',
|
|
36
|
+
]);
|
|
37
|
+
const EASING_FUNCTIONS = new Set(['cubic-bezier', 'linear', 'steps']);
|
|
38
|
+
|
|
39
|
+
const messages = stylelint.utils.ruleMessages(ruleName, {
|
|
40
|
+
invalidDuration: (value) =>
|
|
41
|
+
`Unexpected negative motion duration "${value}". Use non-negative duration values.`,
|
|
42
|
+
rejectedDuration: (value, lower, upper) =>
|
|
43
|
+
`Unexpected motion duration "${value}". Use duration scale values (nearest: ${lower} or ${upper}).`,
|
|
44
|
+
rejectedEasing: (value) =>
|
|
45
|
+
`Unexpected raw motion easing "${value}". Use motion tokens for easing decisions.`,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
function isPlainObject(value) {
|
|
49
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildOptions(rawOptions) {
|
|
53
|
+
const options = rawOptions || {};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
durationScale: normalizeDurationScale(options.durationScale),
|
|
57
|
+
durationUnits: normalizeDurationUnits(options.durationUnits),
|
|
58
|
+
easingTokenMap: isPlainObject(options.easingTokenMap) ? options.easingTokenMap : {},
|
|
59
|
+
fixToScale: options.fixToScale !== false,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function validateSecondaryOptions(result, secondaryOptions) {
|
|
64
|
+
return stylelint.utils.validateOptions(result, ruleName, {
|
|
65
|
+
actual: secondaryOptions,
|
|
66
|
+
optional: true,
|
|
67
|
+
possible: {
|
|
68
|
+
durationScale: [(value) =>
|
|
69
|
+
typeof value === 'number' &&
|
|
70
|
+
Number.isFinite(value) &&
|
|
71
|
+
value >= 0],
|
|
72
|
+
durationUnits: [(value) => value === 'ms' || value === 's'],
|
|
73
|
+
easingTokenMap: [(value) =>
|
|
74
|
+
isPlainObject(value) &&
|
|
75
|
+
Object.values(value).every((entry) =>
|
|
76
|
+
typeof entry === 'string' && entry.trim().length > 0,
|
|
77
|
+
)],
|
|
78
|
+
fixToScale: [true, false],
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getFixedNodeValue(parsedTime, nearestMs, options) {
|
|
84
|
+
if (!options.durationUnits.includes(parsedTime.unit)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const converted = fromMs(nearestMs, parsedTime.unit);
|
|
89
|
+
if (converted === null) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return formatTime(converted, parsedTime.unit);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function functionToString(node) {
|
|
97
|
+
return valueParser.stringify(node);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const ruleFunction = (primary, secondaryOptions) => {
|
|
101
|
+
return (root, result) => {
|
|
102
|
+
const valid = stylelint.utils.validateOptions(result, ruleName, {
|
|
103
|
+
actual: primary,
|
|
104
|
+
possible: [true],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!valid || !validateSecondaryOptions(result, secondaryOptions)) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const options = buildOptions(secondaryOptions);
|
|
112
|
+
|
|
113
|
+
root.walkDecls((decl) => {
|
|
114
|
+
const prop = decl.prop.toLowerCase();
|
|
115
|
+
if (!DURATION_PROPERTIES.has(prop) && !EASING_PROPERTIES.has(prop)) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const parsed = valueParser(decl.value);
|
|
120
|
+
let changed = false;
|
|
121
|
+
|
|
122
|
+
const reportDuration = (node, nearest, fixedValue = null) => {
|
|
123
|
+
const index = declarationValueIndex(decl) + node.sourceIndex;
|
|
124
|
+
const payload = {
|
|
125
|
+
endIndex: index + node.value.length,
|
|
126
|
+
index,
|
|
127
|
+
message: nearest
|
|
128
|
+
? messages.rejectedDuration(
|
|
129
|
+
node.value,
|
|
130
|
+
formatTime(nearest.lower, 'ms'),
|
|
131
|
+
formatTime(nearest.upper, 'ms'),
|
|
132
|
+
)
|
|
133
|
+
: messages.invalidDuration(node.value),
|
|
134
|
+
node: decl,
|
|
135
|
+
result,
|
|
136
|
+
ruleName,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (fixedValue) {
|
|
140
|
+
payload.fix = () => {
|
|
141
|
+
node.value = fixedValue;
|
|
142
|
+
return true;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
stylelint.utils.report(payload);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const reportEasing = (node, replacement = null) => {
|
|
150
|
+
const source = functionToString(node);
|
|
151
|
+
const index = declarationValueIndex(decl) + node.sourceIndex;
|
|
152
|
+
const payload = {
|
|
153
|
+
endIndex: index + source.length,
|
|
154
|
+
index,
|
|
155
|
+
message: messages.rejectedEasing(source),
|
|
156
|
+
node: decl,
|
|
157
|
+
result,
|
|
158
|
+
ruleName,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (replacement) {
|
|
162
|
+
payload.fix = () => {
|
|
163
|
+
node.type = 'word';
|
|
164
|
+
node.value = replacement;
|
|
165
|
+
delete node.nodes;
|
|
166
|
+
return true;
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
stylelint.utils.report(payload);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
walkRootValueNodes(parsed, (node) => {
|
|
174
|
+
if (node.type === 'function') {
|
|
175
|
+
const functionName = node.value.toLowerCase();
|
|
176
|
+
if (!EASING_PROPERTIES.has(prop) || !EASING_FUNCTIONS.has(functionName)) {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const source = functionToString(node);
|
|
181
|
+
reportEasing(node, options.easingTokenMap[source] || null);
|
|
182
|
+
changed = changed || Boolean(options.easingTokenMap[source]);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (node.type !== 'word' || !DURATION_PROPERTIES.has(prop)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const parsedTime = parseTimeToken(node.value);
|
|
191
|
+
if (!parsedTime) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (parsedTime.number < 0) {
|
|
196
|
+
reportDuration(node, null);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!options.durationUnits.includes(parsedTime.unit)) {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const msValue = toMs(parsedTime.number, parsedTime.unit);
|
|
205
|
+
if (msValue === null) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const isOnScale = options.durationScale.some((scaleValue) => numbersEqual(scaleValue, msValue));
|
|
210
|
+
if (isOnScale) {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const nearest = nearestScaleValues(msValue, options.durationScale);
|
|
215
|
+
if (!nearest) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const fixedValue = options.fixToScale
|
|
220
|
+
? getFixedNodeValue(parsedTime, nearest.nearest, options)
|
|
221
|
+
: null;
|
|
222
|
+
|
|
223
|
+
reportDuration(node, nearest, fixedValue);
|
|
224
|
+
changed = changed || Boolean(fixedValue);
|
|
225
|
+
return false;
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (changed) {
|
|
229
|
+
decl.value = parsed.toString();
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
};
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
ruleFunction.ruleName = ruleName;
|
|
236
|
+
ruleFunction.messages = messages;
|
|
237
|
+
ruleFunction.meta = {
|
|
238
|
+
fixable: true,
|
|
239
|
+
url: 'https://github.com/petrilahdelma/stylelint-plugin-rhythmguard#rhythmguarduse-motion-scale',
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
module.exports = stylelint.createPlugin(ruleName, ruleFunction);
|
|
243
|
+
module.exports.ruleName = ruleName;
|
|
244
|
+
module.exports.messages = messages;
|
|
245
|
+
module.exports.meta = ruleFunction.meta;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const require = createRequire(import.meta.url);
|
|
3
|
+
const rule = require('./index.js');
|
|
4
|
+
export default rule;
|
|
5
|
+
export const ruleName = rule.ruleName;
|
|
6
|
+
export const messages = rule.messages;
|
|
7
|
+
export const meta = rule.meta;
|
package/src/utils/constants.js
CHANGED
|
@@ -35,6 +35,10 @@ const PROPERTY_GROUP_PATTERNS = Object.freeze({
|
|
|
35
35
|
/^letter-spacing$/,
|
|
36
36
|
/^word-spacing$/,
|
|
37
37
|
]),
|
|
38
|
+
motion: Object.freeze([
|
|
39
|
+
/^transition(?:-.+)?$/,
|
|
40
|
+
/^animation(?:-.+)?$/,
|
|
41
|
+
]),
|
|
38
42
|
});
|
|
39
43
|
|
|
40
44
|
const DEFAULT_PROPERTY_GROUPS = Object.freeze(['spacing']);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
nearestScaleValues,
|
|
5
|
+
numbersEqual,
|
|
6
|
+
} = require('./length');
|
|
7
|
+
const {
|
|
8
|
+
formatTime,
|
|
9
|
+
fromMs,
|
|
10
|
+
normalizeDurationScale,
|
|
11
|
+
normalizeDurationUnits,
|
|
12
|
+
parseTimeToken,
|
|
13
|
+
toMs,
|
|
14
|
+
} = require('./time');
|
|
15
|
+
const { findClassSegments } = require('./tailwind-class-analysis');
|
|
16
|
+
|
|
17
|
+
const ARBITRARY_MOTION_CLASS = /^(?<utility>duration|delay|ease)-\[(?<rawValue>[^\]]+)\]$/;
|
|
18
|
+
|
|
19
|
+
function normalizeTailwindMotionOptions(option = {}) {
|
|
20
|
+
return {
|
|
21
|
+
durationScale: normalizeDurationScale(option.durationScale),
|
|
22
|
+
durationUnits: normalizeDurationUnits(option.durationUnits),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function findLastVariantSeparator(token) {
|
|
27
|
+
let bracketDepth = 0;
|
|
28
|
+
let separatorIndex = -1;
|
|
29
|
+
|
|
30
|
+
for (let index = 0; index < token.length; index++) {
|
|
31
|
+
const character = token[index];
|
|
32
|
+
|
|
33
|
+
if (character === '[') {
|
|
34
|
+
bracketDepth++;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (character === ']' && bracketDepth > 0) {
|
|
39
|
+
bracketDepth--;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (character === ':' && bracketDepth === 0) {
|
|
44
|
+
separatorIndex = index;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return separatorIndex;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseClassToken(token) {
|
|
52
|
+
const separatorIndex = findLastVariantSeparator(token);
|
|
53
|
+
const prefix = separatorIndex === -1
|
|
54
|
+
? ''
|
|
55
|
+
: token.slice(0, separatorIndex + 1);
|
|
56
|
+
let candidate = separatorIndex === -1
|
|
57
|
+
? token
|
|
58
|
+
: token.slice(separatorIndex + 1);
|
|
59
|
+
let leadingImportant = '';
|
|
60
|
+
let trailingImportant = '';
|
|
61
|
+
|
|
62
|
+
if (candidate.startsWith('!')) {
|
|
63
|
+
leadingImportant = '!';
|
|
64
|
+
candidate = candidate.slice(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (candidate.endsWith('!')) {
|
|
68
|
+
trailingImportant = '!';
|
|
69
|
+
candidate = candidate.slice(0, -1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
candidate,
|
|
74
|
+
leadingImportant,
|
|
75
|
+
prefix,
|
|
76
|
+
trailingImportant,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function analyzeMotionToken(token, options) {
|
|
81
|
+
const parsedToken = parseClassToken(token);
|
|
82
|
+
const match = parsedToken.candidate.match(ARBITRARY_MOTION_CLASS);
|
|
83
|
+
if (!match || !match.groups) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (match.groups.utility === 'ease') {
|
|
88
|
+
return {
|
|
89
|
+
fixedToken: null,
|
|
90
|
+
rawValue: match.groups.rawValue,
|
|
91
|
+
reason: 'easing',
|
|
92
|
+
utility: match.groups.utility,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const parsedTime = parseTimeToken(match.groups.rawValue);
|
|
97
|
+
if (!parsedTime) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (parsedTime.number < 0) {
|
|
102
|
+
return {
|
|
103
|
+
fixedToken: null,
|
|
104
|
+
nearest: null,
|
|
105
|
+
rawValue: match.groups.rawValue,
|
|
106
|
+
reason: 'negative',
|
|
107
|
+
utility: match.groups.utility,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!options.durationUnits.includes(parsedTime.unit)) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const msValue = toMs(parsedTime.number, parsedTime.unit);
|
|
116
|
+
if (msValue === null) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isOnScale = options.durationScale.some((entry) => numbersEqual(entry, msValue));
|
|
121
|
+
if (isOnScale) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const nearest = nearestScaleValues(msValue, options.durationScale);
|
|
126
|
+
if (!nearest) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const converted = fromMs(nearest.nearest, parsedTime.unit);
|
|
131
|
+
if (converted === null) {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const replacementValue = formatTime(converted, parsedTime.unit);
|
|
136
|
+
const fixedCandidate = parsedToken.candidate.replace(match.groups.rawValue, replacementValue);
|
|
137
|
+
const fixedToken = `${parsedToken.prefix}${parsedToken.leadingImportant}${fixedCandidate}${parsedToken.trailingImportant}`;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
fixedToken,
|
|
141
|
+
nearest,
|
|
142
|
+
rawValue: match.groups.rawValue,
|
|
143
|
+
reason: 'duration',
|
|
144
|
+
utility: match.groups.utility,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createTailwindMotionAnalyzer(option = {}) {
|
|
149
|
+
const options = normalizeTailwindMotionOptions(option);
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
analyzeClassString(value) {
|
|
153
|
+
const findings = [];
|
|
154
|
+
|
|
155
|
+
for (const segment of findClassSegments(value)) {
|
|
156
|
+
const analysis = analyzeMotionToken(segment.token, options);
|
|
157
|
+
if (analysis) {
|
|
158
|
+
findings.push({ analysis, segment });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return findings;
|
|
163
|
+
},
|
|
164
|
+
analyzeToken(token) {
|
|
165
|
+
return analyzeMotionToken(token, options);
|
|
166
|
+
},
|
|
167
|
+
options,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = {
|
|
172
|
+
createTailwindMotionAnalyzer,
|
|
173
|
+
normalizeTailwindMotionOptions,
|
|
174
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const TIME_RE = /^(-?(?:\d+|\d*\.\d+))(ms|s)$/i;
|
|
4
|
+
|
|
5
|
+
function parseTimeToken(value) {
|
|
6
|
+
if (typeof value !== 'string') {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const match = value.trim().match(TIME_RE);
|
|
11
|
+
if (!match) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const number = Number(match[1]);
|
|
16
|
+
if (!Number.isFinite(number)) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
number,
|
|
22
|
+
unit: match[2].toLowerCase(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toMs(number, unit) {
|
|
27
|
+
if (unit === 'ms') {
|
|
28
|
+
return number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (unit === 's') {
|
|
32
|
+
return number * 1000;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function fromMs(ms, unit) {
|
|
39
|
+
if (unit === 'ms') {
|
|
40
|
+
return ms;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (unit === 's') {
|
|
44
|
+
return ms / 1000;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatTime(number, unit) {
|
|
51
|
+
const normalized = Object.is(number, -0) ? 0 : number;
|
|
52
|
+
if (Number.isInteger(normalized)) {
|
|
53
|
+
return `${normalized}${unit}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return `${Number(normalized.toFixed(4)).toString()}${unit}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeDurationScale(scale) {
|
|
60
|
+
const source = Array.isArray(scale) && scale.length > 0
|
|
61
|
+
? scale
|
|
62
|
+
: [0, 75, 100, 150, 200, 300, 500, 700, 1000];
|
|
63
|
+
|
|
64
|
+
return [...new Set(source
|
|
65
|
+
.map((entry) => Number(entry))
|
|
66
|
+
.filter((entry) => Number.isFinite(entry) && entry >= 0))]
|
|
67
|
+
.sort((a, b) => a - b);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normalizeDurationUnits(units) {
|
|
71
|
+
const source = Array.isArray(units) && units.length > 0
|
|
72
|
+
? units
|
|
73
|
+
: ['ms', 's'];
|
|
74
|
+
|
|
75
|
+
const normalized = source
|
|
76
|
+
.map((unit) => String(unit).trim().toLowerCase())
|
|
77
|
+
.filter((unit) => unit === 'ms' || unit === 's');
|
|
78
|
+
|
|
79
|
+
return normalized.length > 0 ? [...new Set(normalized)] : ['ms', 's'];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = {
|
|
83
|
+
formatTime,
|
|
84
|
+
fromMs,
|
|
85
|
+
normalizeDurationScale,
|
|
86
|
+
normalizeDurationUnits,
|
|
87
|
+
parseTimeToken,
|
|
88
|
+
toMs,
|
|
89
|
+
};
|
|
@@ -22,12 +22,14 @@ const VALID_TOKEN_KINDS = new Set([
|
|
|
22
22
|
'radius',
|
|
23
23
|
'typography',
|
|
24
24
|
'size',
|
|
25
|
+
'motion',
|
|
25
26
|
'all',
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
const TOKEN_KIND_PATTERNS = Object.freeze({
|
|
29
30
|
all: /^--/,
|
|
30
31
|
radius: /^--radius-/,
|
|
32
|
+
motion: /^--(?:motion|duration|delay|ease|easing)-/,
|
|
31
33
|
size: /^--(?:size|width|height|container)-/,
|
|
32
34
|
spacing: /^--(?:space|spacing)-/,
|
|
33
35
|
typography: /^--(?:font|font-size|line-height|leading|tracking|typography)-/,
|
|
@@ -53,7 +55,7 @@ function normalizeTokenSourceFormat(format) {
|
|
|
53
55
|
function normalizeTokenKind(kind) {
|
|
54
56
|
const normalized = String(kind || 'spacing').trim().toLowerCase();
|
|
55
57
|
if (!VALID_TOKEN_KINDS.has(normalized)) {
|
|
56
|
-
throw new Error(`Invalid token kind "${kind}". Expected spacing, radius, typography, size, or all.`);
|
|
58
|
+
throw new Error(`Invalid token kind "${kind}". Expected spacing, radius, typography, size, motion, or all.`);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
return normalized;
|