@atlaskit/eslint-plugin-design-system 15.0.0 → 15.1.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/README.md +1 -0
- package/dist/cjs/presets/all-flat.codegen.js +2 -1
- package/dist/cjs/presets/all.codegen.js +2 -1
- package/dist/cjs/rules/index.codegen.js +3 -1
- package/dist/cjs/rules/lozenge-badge-tag-labelling-system-migration/index.js +27 -124
- package/dist/cjs/rules/use-tokens-motion/index.js +555 -0
- package/dist/es2019/presets/all-flat.codegen.js +2 -1
- package/dist/es2019/presets/all.codegen.js +2 -1
- package/dist/es2019/rules/index.codegen.js +3 -1
- package/dist/es2019/rules/lozenge-badge-tag-labelling-system-migration/index.js +23 -120
- package/dist/es2019/rules/use-tokens-motion/index.js +489 -0
- package/dist/esm/presets/all-flat.codegen.js +2 -1
- package/dist/esm/presets/all.codegen.js +2 -1
- package/dist/esm/rules/index.codegen.js +3 -1
- package/dist/esm/rules/lozenge-badge-tag-labelling-system-migration/index.js +27 -124
- package/dist/esm/rules/use-tokens-motion/index.js +548 -0
- package/dist/types/presets/all-flat.codegen.d.ts +1 -1
- package/dist/types/presets/all.codegen.d.ts +1 -1
- package/dist/types/rules/index.codegen.d.ts +1 -1
- package/dist/types/rules/use-tokens-motion/index.d.ts +3 -0
- package/package.json +1 -1
|
@@ -12,9 +12,7 @@ const rule = createLintRule({
|
|
|
12
12
|
},
|
|
13
13
|
messages: {
|
|
14
14
|
updateAppearance: 'Update appearance value to new semantic value.',
|
|
15
|
-
migrateTag: '
|
|
16
|
-
manualReview: "Dynamic 'isBold' props require manual review before migration.",
|
|
17
|
-
dynamicLozengeAppearance: "Dynamic 'appearance' prop values require manual review before migrating to Tag. Please verify the appearance value and manually convert it to the appropriate color prop value.",
|
|
15
|
+
migrateTag: '<SimpleTag> and <RemovableTag> components should migrate to the new <Tag> or <AvatarTag> component.',
|
|
18
16
|
updateBadgeAppearance: 'Update Badge appearance value "{{oldValue}}" to new semantic value "{{newValue}}".',
|
|
19
17
|
dynamicBadgeAppearance: 'Dynamic appearance prop values require manual review to ensure they use the new semantic values: neutral, information, inverse, danger, success.'
|
|
20
18
|
}
|
|
@@ -60,13 +58,6 @@ const rule = createLintRule({
|
|
|
60
58
|
*/
|
|
61
59
|
const newTagImports = {};
|
|
62
60
|
|
|
63
|
-
/**
|
|
64
|
-
* Check if a JSX attribute value is a literal false
|
|
65
|
-
*/
|
|
66
|
-
function isLiteralFalse(node) {
|
|
67
|
-
return node && node.type === 'JSXExpressionContainer' && node.expression && node.expression.type === 'Literal' && node.expression.value === false;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
61
|
/**
|
|
71
62
|
* Check if a JSX attribute value is dynamic (not a static literal value)
|
|
72
63
|
* Can be used for any prop type (boolean, string, etc.)
|
|
@@ -103,37 +94,22 @@ const rule = createLintRule({
|
|
|
103
94
|
}
|
|
104
95
|
|
|
105
96
|
/**
|
|
106
|
-
* Map old appearance values to new semantic appearance values
|
|
107
|
-
*
|
|
97
|
+
* Map old Lozenge appearance values to new semantic appearance values.
|
|
98
|
+
* The new Lozenge no longer uses legacy values — it uses semantic color names
|
|
99
|
+
* that align with the new labelling system.
|
|
108
100
|
*/
|
|
109
101
|
function mapToNewAppearanceValue(oldValue) {
|
|
110
102
|
const mapping = {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
new: '
|
|
116
|
-
|
|
103
|
+
default: 'neutral',
|
|
104
|
+
inprogress: 'information',
|
|
105
|
+
moved: 'warning',
|
|
106
|
+
removed: 'danger',
|
|
107
|
+
new: 'discovery',
|
|
108
|
+
success: 'success'
|
|
117
109
|
};
|
|
118
110
|
return mapping[oldValue] || oldValue;
|
|
119
111
|
}
|
|
120
112
|
|
|
121
|
-
/**
|
|
122
|
-
* Map Lozenge appearance values to Tag color values
|
|
123
|
-
* Used when migrating Lozenge to Tag component
|
|
124
|
-
*/
|
|
125
|
-
function mapLozengeAppearanceToTagColor(appearanceValue) {
|
|
126
|
-
const mapping = {
|
|
127
|
-
success: 'lime',
|
|
128
|
-
default: 'gray',
|
|
129
|
-
removed: 'red',
|
|
130
|
-
inprogress: 'blue',
|
|
131
|
-
new: 'purple',
|
|
132
|
-
moved: 'yellow'
|
|
133
|
-
};
|
|
134
|
-
return mapping[appearanceValue] || appearanceValue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
113
|
/**
|
|
138
114
|
* Map Badge old appearance values to new semantic appearance values
|
|
139
115
|
*/
|
|
@@ -259,7 +235,7 @@ const rule = createLintRule({
|
|
|
259
235
|
|
|
260
236
|
/**
|
|
261
237
|
* Generate the replacement JSX element text for Tag migration
|
|
262
|
-
* Handles both regular Tag and
|
|
238
|
+
* Handles both regular Tag and AvatarTag migrations for SimpleTag/RemovableTag.
|
|
263
239
|
*/
|
|
264
240
|
function generateTagReplacement(node, options = {}) {
|
|
265
241
|
var _context$sourceCode;
|
|
@@ -277,20 +253,7 @@ const rule = createLintRule({
|
|
|
277
253
|
return;
|
|
278
254
|
}
|
|
279
255
|
if (attrName === 'appearance') {
|
|
280
|
-
//
|
|
281
|
-
// For SimpleTag/RemovableTag migrations, delete appearance prop
|
|
282
|
-
if (options.isLozengeMigration) {
|
|
283
|
-
// Map Lozenge appearance value to Tag color value and change prop name from appearance to color
|
|
284
|
-
const stringValue = extractStringValue(attr.value);
|
|
285
|
-
if (stringValue && typeof stringValue === 'string') {
|
|
286
|
-
const mappedColor = mapLozengeAppearanceToTagColor(stringValue);
|
|
287
|
-
newAttributes.push(`color="${mappedColor}"`);
|
|
288
|
-
}
|
|
289
|
-
// If we can't extract the string value (dynamic expression), skip it
|
|
290
|
-
// Dynamic expressions should be caught earlier and require manual review
|
|
291
|
-
// This code path shouldn't be reached, but we skip to be safe
|
|
292
|
-
}
|
|
293
|
-
// For SimpleTag/RemovableTag migrations, skip appearance prop (delete it)
|
|
256
|
+
// Delete appearance prop — not used in new Tag/AvatarTag API
|
|
294
257
|
return;
|
|
295
258
|
}
|
|
296
259
|
if (attrName === 'color') {
|
|
@@ -352,15 +315,10 @@ const rule = createLintRule({
|
|
|
352
315
|
}
|
|
353
316
|
});
|
|
354
317
|
|
|
355
|
-
// Add isRemovable={false} for SimpleTag migrations
|
|
356
|
-
if (options.isSimpleTag
|
|
318
|
+
// Add isRemovable={false} for SimpleTag migrations
|
|
319
|
+
if (options.isSimpleTag) {
|
|
357
320
|
newAttributes.push('isRemovable={false}');
|
|
358
321
|
}
|
|
359
|
-
|
|
360
|
-
// Add migration_fallback="lozenge" for Lozenge migrations to enable safe staged rollout
|
|
361
|
-
if (options.isLozengeMigration) {
|
|
362
|
-
newAttributes.push('migration_fallback="lozenge"');
|
|
363
|
-
}
|
|
364
322
|
const attributesText = newAttributes.length > 0 ? ` ${newAttributes.join(' ')}` : '';
|
|
365
323
|
const children = node.children.length > 0 ? sourceCode.getText().slice(node.openingElement.range[1], node.closingElement ? node.closingElement.range[0] : node.range[1]) : '';
|
|
366
324
|
const componentName = options.preserveComponentName ? node.openingElement.name.name : options.isAvatarTag ? 'AvatarTag' : 'Tag';
|
|
@@ -632,77 +590,22 @@ const rule = createLintRule({
|
|
|
632
590
|
}
|
|
633
591
|
const attributesMap = getAttributesMap(node.openingElement.attributes);
|
|
634
592
|
const appearanceProp = attributesMap.appearance;
|
|
635
|
-
const isBoldProp = attributesMap.isBold;
|
|
636
593
|
|
|
637
|
-
// Handle appearance prop value migration
|
|
594
|
+
// Handle appearance prop value migration — always update to new semantic values.
|
|
595
|
+
// isBold is intentionally not flagged: users may still need it while the feature flag
|
|
596
|
+
// platform-dst-lozenge-tag-badge-visual-uplifts is OFF (subtle variant still rendered).
|
|
638
597
|
if (appearanceProp) {
|
|
639
|
-
const
|
|
640
|
-
if (
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (stringValue && typeof stringValue === 'string') {
|
|
644
|
-
const mappedValue = mapToNewAppearanceValue(stringValue);
|
|
645
|
-
if (mappedValue !== stringValue) {
|
|
646
|
-
context.report({
|
|
647
|
-
node: appearanceProp,
|
|
648
|
-
messageId: 'updateAppearance',
|
|
649
|
-
fix: createAppearanceFixer(appearanceProp.value, mappedValue)
|
|
650
|
-
});
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// Handle isBold prop and Tag migration
|
|
657
|
-
if (isBoldProp) {
|
|
658
|
-
if (isLiteralFalse(isBoldProp.value)) {
|
|
659
|
-
// isBold={false} should migrate to Tag
|
|
660
|
-
// Check if appearance is dynamic - if so, require manual review
|
|
661
|
-
if (appearanceProp && isDynamicExpression(appearanceProp.value)) {
|
|
598
|
+
const stringValue = extractStringValue(appearanceProp.value);
|
|
599
|
+
if (stringValue && typeof stringValue === 'string') {
|
|
600
|
+
const mappedValue = mapToNewAppearanceValue(stringValue);
|
|
601
|
+
if (mappedValue !== stringValue) {
|
|
662
602
|
context.report({
|
|
663
603
|
node: appearanceProp,
|
|
664
|
-
messageId: '
|
|
604
|
+
messageId: 'updateAppearance',
|
|
605
|
+
fix: createAppearanceFixer(appearanceProp.value, mappedValue)
|
|
665
606
|
});
|
|
666
|
-
return;
|
|
667
607
|
}
|
|
668
|
-
context.report({
|
|
669
|
-
node: node,
|
|
670
|
-
messageId: 'migrateTag',
|
|
671
|
-
fix: fixer => {
|
|
672
|
-
const replacement = generateTagReplacement(node, {
|
|
673
|
-
isLozengeMigration: true
|
|
674
|
-
});
|
|
675
|
-
return fixer.replaceText(node, replacement);
|
|
676
|
-
}
|
|
677
|
-
});
|
|
678
|
-
} else if (isDynamicExpression(isBoldProp.value)) {
|
|
679
|
-
// Dynamic isBold requires manual review
|
|
680
|
-
context.report({
|
|
681
|
-
node: isBoldProp,
|
|
682
|
-
messageId: 'manualReview'
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
// isBold={true} or isBold (implicit true) - no action needed
|
|
686
|
-
} else {
|
|
687
|
-
// No isBold prop means implicit false, should migrate to Tag
|
|
688
|
-
// Check if appearance is dynamic - if so, require manual review
|
|
689
|
-
if (appearanceProp && isDynamicExpression(appearanceProp.value)) {
|
|
690
|
-
context.report({
|
|
691
|
-
node: appearanceProp,
|
|
692
|
-
messageId: 'dynamicLozengeAppearance'
|
|
693
|
-
});
|
|
694
|
-
return;
|
|
695
608
|
}
|
|
696
|
-
context.report({
|
|
697
|
-
node: node,
|
|
698
|
-
messageId: 'migrateTag',
|
|
699
|
-
fix: fixer => {
|
|
700
|
-
const replacement = generateTagReplacement(node, {
|
|
701
|
-
isLozengeMigration: true
|
|
702
|
-
});
|
|
703
|
-
return fixer.replaceText(node, replacement);
|
|
704
|
-
}
|
|
705
|
-
});
|
|
706
609
|
}
|
|
707
610
|
}
|
|
708
611
|
};
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import tokenDefaultValues from '@atlaskit/tokens/token-default-values';
|
|
2
|
+
import { createLintRule } from '../utils/create-lint-rule';
|
|
3
|
+
const DURATION_TOKEN_NAMES = ['motion.duration.instant', 'motion.duration.xxshort', 'motion.duration.xshort', 'motion.duration.short', 'motion.duration.medium', 'motion.duration.long', 'motion.duration.xlong', 'motion.duration.xxlong'];
|
|
4
|
+
function parseDurationMs(value) {
|
|
5
|
+
const ms = value.match(/^(\d+(?:\.\d+)?)ms$/);
|
|
6
|
+
if (ms) {
|
|
7
|
+
return parseFloat(ms[1]);
|
|
8
|
+
}
|
|
9
|
+
const s = value.match(/^(\d+(?:\.\d+)?)s$/);
|
|
10
|
+
if (s) {
|
|
11
|
+
return parseFloat(s[1]) * 1000;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const DURATION_TOKENS = DURATION_TOKEN_NAMES.map(name => {
|
|
16
|
+
const rawValue = tokenDefaultValues[name];
|
|
17
|
+
const ms = parseDurationMs(rawValue);
|
|
18
|
+
if (ms === null) {
|
|
19
|
+
throw new Error(`use-tokens-motion: could not parse duration for token ${name}: ${rawValue}`);
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
ms,
|
|
23
|
+
token: name
|
|
24
|
+
};
|
|
25
|
+
}).sort((a, b) => a.ms - b.ms);
|
|
26
|
+
const EASING_TOKEN_NAMES = ['motion.easing.in.practical', 'motion.easing.inout.bold', 'motion.easing.out.practical', 'motion.easing.out.bold'];
|
|
27
|
+
function parseCubicBezierParams(value) {
|
|
28
|
+
const match = value.match(/^cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
|
|
29
|
+
if (!match) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])];
|
|
33
|
+
}
|
|
34
|
+
const EASING_TOKENS = EASING_TOKEN_NAMES.map(name => {
|
|
35
|
+
const rawValue = tokenDefaultValues[name];
|
|
36
|
+
const params = parseCubicBezierParams(rawValue);
|
|
37
|
+
if (!params) {
|
|
38
|
+
throw new Error(`use-tokens-motion: could not parse cubic-bezier for token ${name}: ${rawValue}`);
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
value: rawValue,
|
|
42
|
+
token: name,
|
|
43
|
+
params
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Splits on top-level commas (outside function parens) — preserves cubic-bezier(...) commas.
|
|
48
|
+
function splitOnTopLevelCommas(value) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let current = '';
|
|
52
|
+
for (const ch of value) {
|
|
53
|
+
if (ch === '(') {
|
|
54
|
+
depth++;
|
|
55
|
+
current += ch;
|
|
56
|
+
} else if (ch === ')') {
|
|
57
|
+
depth--;
|
|
58
|
+
current += ch;
|
|
59
|
+
} else if (ch === ',' && depth === 0) {
|
|
60
|
+
parts.push(current.trim());
|
|
61
|
+
current = '';
|
|
62
|
+
} else {
|
|
63
|
+
current += ch;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (current.trim().length > 0) {
|
|
67
|
+
parts.push(current.trim());
|
|
68
|
+
}
|
|
69
|
+
return parts;
|
|
70
|
+
}
|
|
71
|
+
const DURATION_PROPERTIES = new Set(['transitionDuration', 'animationDuration']);
|
|
72
|
+
const EASING_PROPERTIES = new Set(['transitionTimingFunction', 'animationTimingFunction']);
|
|
73
|
+
|
|
74
|
+
// Explicit semantic mappings for CSS keyword easings to motion tokens.
|
|
75
|
+
// Pinned by design intent, confirmed with design system team (Alex + Akshay).
|
|
76
|
+
const CSS_KEYWORD_EASING_TOKEN_MAP = {
|
|
77
|
+
ease: 'motion.easing.out.practical',
|
|
78
|
+
'ease-out': 'motion.easing.out.practical',
|
|
79
|
+
'ease-in': 'motion.easing.in.practical',
|
|
80
|
+
'ease-in-out': 'motion.easing.inout.bold'
|
|
81
|
+
// linear (0,0,1,1) — warn only, no autofix (per Akshay: too generic, no good token match)
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Non-curve easing values with no meaningful cubic-bezier representation — skip entirely
|
|
85
|
+
const SKIP_EASING_VALUES = new Set(['step-start', 'step-end', 'inherit', 'initial', 'unset', 'none']);
|
|
86
|
+
function euclideanDistance(a, b) {
|
|
87
|
+
return Math.sqrt(a.reduce((sum, val, i) => sum + Math.pow(val - b[i], 2), 0));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Maximum Euclidean distance for easing autofix — beyond this threshold, we report-only
|
|
91
|
+
const EASING_AUTOFIX_THRESHOLD = 0.5;
|
|
92
|
+
function findClosestEasingToken(params) {
|
|
93
|
+
let minDist = Infinity;
|
|
94
|
+
let closest = EASING_TOKENS[0];
|
|
95
|
+
for (const entry of EASING_TOKENS) {
|
|
96
|
+
const dist = euclideanDistance(params, entry.params);
|
|
97
|
+
if (dist < minDist) {
|
|
98
|
+
minDist = dist;
|
|
99
|
+
closest = entry;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (minDist > EASING_AUTOFIX_THRESHOLD) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
token: closest.token,
|
|
107
|
+
value: closest.value,
|
|
108
|
+
dist: minDist
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function findClosestDurationTokens(ms) {
|
|
112
|
+
const exact = DURATION_TOKENS.find(t => t.ms === ms);
|
|
113
|
+
if (exact) {
|
|
114
|
+
return [exact];
|
|
115
|
+
}
|
|
116
|
+
let minDist = Infinity;
|
|
117
|
+
for (const entry of DURATION_TOKENS) {
|
|
118
|
+
const dist = Math.abs(entry.ms - ms);
|
|
119
|
+
if (dist < minDist) {
|
|
120
|
+
minDist = dist;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const closest = DURATION_TOKENS.filter(t => Math.abs(t.ms - ms) === minDist);
|
|
124
|
+
return closest;
|
|
125
|
+
}
|
|
126
|
+
const useTokensMotion = createLintRule({
|
|
127
|
+
meta: {
|
|
128
|
+
name: 'use-tokens-motion',
|
|
129
|
+
type: 'suggestion',
|
|
130
|
+
hasSuggestions: true,
|
|
131
|
+
docs: {
|
|
132
|
+
description: 'Enforces usage of motion design tokens rather than hard-coded duration and easing values.',
|
|
133
|
+
recommended: false,
|
|
134
|
+
severity: 'warn'
|
|
135
|
+
},
|
|
136
|
+
messages: {
|
|
137
|
+
useMotionDurationToken: "Use a motion duration token instead of the hard-coded value '{{ value }}'.",
|
|
138
|
+
useMotionDurationTokenSuggest: 'Replace with {{ suggestion }}.',
|
|
139
|
+
useMotionDurationTokenNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion1 }} or {{ suggestion2 }}.",
|
|
140
|
+
useMotionDurationTokenSingleNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion }}.",
|
|
141
|
+
useMotionEasingToken: "Use a motion easing token instead of the hard-coded value '{{ value }}'.",
|
|
142
|
+
useMotionEasingTokenSuggest: 'Replace with {{ suggestion }}.',
|
|
143
|
+
useMotionEasingTokenUnknown: "Use a motion easing token from @atlaskit/tokens instead of the hard-coded value '{{ value }}'."
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
create(context) {
|
|
147
|
+
let tokensImportNode = null;
|
|
148
|
+
let hasTokenSpecifier = false;
|
|
149
|
+
function buildTokenCall(tokenName, fallback) {
|
|
150
|
+
return `token('${tokenName}', '${fallback}')`;
|
|
151
|
+
}
|
|
152
|
+
function getImportFix(fixer) {
|
|
153
|
+
var _context$sourceCode;
|
|
154
|
+
if (hasTokenSpecifier) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
if (tokensImportNode) {
|
|
158
|
+
// @atlaskit/tokens is imported but without `token` — add `token` to existing import
|
|
159
|
+
const lastSpecifier = tokensImportNode.specifiers[tokensImportNode.specifiers.length - 1];
|
|
160
|
+
if (lastSpecifier) {
|
|
161
|
+
return [fixer.insertTextAfter(lastSpecifier, ', token')];
|
|
162
|
+
}
|
|
163
|
+
// Empty import — replace the whole declaration
|
|
164
|
+
return [fixer.replaceText(tokensImportNode, `import { token } from '@atlaskit/tokens';`)];
|
|
165
|
+
}
|
|
166
|
+
const sourceCode = (_context$sourceCode = context.sourceCode) !== null && _context$sourceCode !== void 0 ? _context$sourceCode : context.getSourceCode();
|
|
167
|
+
const programBody = sourceCode.ast.body;
|
|
168
|
+
// Insert after the last existing import, or at top if no imports exist
|
|
169
|
+
const lastImport = [...programBody].reverse().find(n => n.type === 'ImportDeclaration');
|
|
170
|
+
if (lastImport) {
|
|
171
|
+
return [fixer.insertTextAfter(lastImport, `\nimport { token } from '@atlaskit/tokens';`)];
|
|
172
|
+
}
|
|
173
|
+
if (programBody.length > 0) {
|
|
174
|
+
return [fixer.insertTextBefore(programBody[0], `import { token } from '@atlaskit/tokens';\n`)];
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Returns autofix string for a single duration value, or null if ambiguous (equidistant)
|
|
180
|
+
function resolveDurationToken(value) {
|
|
181
|
+
const ms = parseDurationMs(value);
|
|
182
|
+
if (ms === null) {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
const exact = DURATION_TOKENS.find(t => t.ms === ms);
|
|
186
|
+
if (exact) {
|
|
187
|
+
return buildTokenCall(exact.token, value);
|
|
188
|
+
}
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
function handleDurationProperty(node, rawValue) {
|
|
192
|
+
const segments = splitOnTopLevelCommas(rawValue);
|
|
193
|
+
if (segments.length === 1) {
|
|
194
|
+
const ms = parseDurationMs(rawValue);
|
|
195
|
+
if (ms === null) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const exactMatch = DURATION_TOKENS.find(t => t.ms === ms);
|
|
199
|
+
if (exactMatch) {
|
|
200
|
+
const suggestion = buildTokenCall(exactMatch.token, rawValue);
|
|
201
|
+
context.report({
|
|
202
|
+
node,
|
|
203
|
+
messageId: 'useMotionDurationToken',
|
|
204
|
+
data: {
|
|
205
|
+
value: rawValue
|
|
206
|
+
},
|
|
207
|
+
suggest: [{
|
|
208
|
+
messageId: 'useMotionDurationTokenSuggest',
|
|
209
|
+
data: {
|
|
210
|
+
suggestion
|
|
211
|
+
},
|
|
212
|
+
fix(fixer) {
|
|
213
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
|
|
214
|
+
}
|
|
215
|
+
}]
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
const result = findClosestDurationTokens(ms);
|
|
219
|
+
if (result.length >= 2) {
|
|
220
|
+
const suggestion1 = buildTokenCall(result[0].token, rawValue);
|
|
221
|
+
const suggestion2 = buildTokenCall(result[1].token, rawValue);
|
|
222
|
+
context.report({
|
|
223
|
+
node,
|
|
224
|
+
messageId: 'useMotionDurationTokenNearest',
|
|
225
|
+
data: {
|
|
226
|
+
value: rawValue,
|
|
227
|
+
suggestion1: `${suggestion1} (${result[0].ms}ms)`,
|
|
228
|
+
suggestion2: `${suggestion2} (${result[1].ms}ms)`
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
const suggestion = buildTokenCall(result[0].token, rawValue);
|
|
233
|
+
context.report({
|
|
234
|
+
node,
|
|
235
|
+
messageId: 'useMotionDurationTokenSingleNearest',
|
|
236
|
+
data: {
|
|
237
|
+
value: rawValue,
|
|
238
|
+
suggestion: `${suggestion} (${result[0].ms}ms)`
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const resolved = segments.map(resolveDurationToken);
|
|
246
|
+
if (resolved.some(s => s === null)) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const templateLiteral = '`' + resolved.map(s => `\${${s}}`).join(', ') + '`';
|
|
250
|
+
context.report({
|
|
251
|
+
node,
|
|
252
|
+
messageId: 'useMotionDurationToken',
|
|
253
|
+
data: {
|
|
254
|
+
value: rawValue
|
|
255
|
+
},
|
|
256
|
+
suggest: [{
|
|
257
|
+
messageId: 'useMotionDurationTokenSuggest',
|
|
258
|
+
data: {
|
|
259
|
+
suggestion: templateLiteral
|
|
260
|
+
},
|
|
261
|
+
fix(fixer) {
|
|
262
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, templateLiteral)];
|
|
263
|
+
}
|
|
264
|
+
}]
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Returns autofix string for a single easing value, or null if no token suggestion is possible
|
|
269
|
+
function resolveEasingToken(value) {
|
|
270
|
+
const trimmed = value.trim();
|
|
271
|
+
if (SKIP_EASING_VALUES.has(trimmed)) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
|
|
275
|
+
return buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
|
|
276
|
+
}
|
|
277
|
+
// linear has no curve (0,0,1,1) — warn only, no autofix
|
|
278
|
+
if (trimmed === 'linear') {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (trimmed.startsWith('linear(')) {
|
|
282
|
+
// linear() is used for spring animations — motion.easing.spring is experimental, skip
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
const params = parseCubicBezierParams(trimmed);
|
|
286
|
+
if (!params) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
const exact = EASING_TOKENS.find(t => t.value === trimmed);
|
|
290
|
+
if (exact) {
|
|
291
|
+
return buildTokenCall(exact.token, trimmed);
|
|
292
|
+
}
|
|
293
|
+
const closest = findClosestEasingToken(params);
|
|
294
|
+
return closest ? buildTokenCall(closest.token, trimmed) : null;
|
|
295
|
+
}
|
|
296
|
+
function handleEasingProperty(node, rawValue) {
|
|
297
|
+
const segments = splitOnTopLevelCommas(rawValue);
|
|
298
|
+
|
|
299
|
+
// Multi-value path: resolve each segment, autofix only if all resolve cleanly
|
|
300
|
+
if (segments.length > 1) {
|
|
301
|
+
const resolved = segments.map(resolveEasingToken);
|
|
302
|
+
if (resolved.some(s => s === null)) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const templateLiteral = '`' + resolved.map(s => `\${${s}}`).join(', ') + '`';
|
|
306
|
+
context.report({
|
|
307
|
+
node,
|
|
308
|
+
messageId: 'useMotionEasingToken',
|
|
309
|
+
data: {
|
|
310
|
+
value: rawValue
|
|
311
|
+
},
|
|
312
|
+
suggest: [{
|
|
313
|
+
messageId: 'useMotionEasingTokenSuggest',
|
|
314
|
+
data: {
|
|
315
|
+
suggestion: templateLiteral
|
|
316
|
+
},
|
|
317
|
+
fix(fixer) {
|
|
318
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, templateLiteral)];
|
|
319
|
+
}
|
|
320
|
+
}]
|
|
321
|
+
});
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
const trimmed = rawValue.trim();
|
|
325
|
+
if (SKIP_EASING_VALUES.has(trimmed)) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// CSS keyword easings: convert to cubic-bezier equivalent and find closest token
|
|
330
|
+
if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
|
|
331
|
+
const suggestion = buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
|
|
332
|
+
context.report({
|
|
333
|
+
node,
|
|
334
|
+
messageId: 'useMotionEasingToken',
|
|
335
|
+
data: {
|
|
336
|
+
value: trimmed
|
|
337
|
+
},
|
|
338
|
+
suggest: [{
|
|
339
|
+
messageId: 'useMotionEasingTokenSuggest',
|
|
340
|
+
data: {
|
|
341
|
+
suggestion
|
|
342
|
+
},
|
|
343
|
+
fix(fixer) {
|
|
344
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
|
|
345
|
+
}
|
|
346
|
+
}]
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// linear has no curve (0,0,1,1) — warn only, no autofix
|
|
351
|
+
if (trimmed === 'linear') {
|
|
352
|
+
context.report({
|
|
353
|
+
node,
|
|
354
|
+
messageId: 'useMotionEasingTokenUnknown',
|
|
355
|
+
data: {
|
|
356
|
+
value: trimmed
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (trimmed.startsWith('linear(')) {
|
|
362
|
+
// linear() is used for spring animations — motion.easing.spring is experimental, skip
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const params = parseCubicBezierParams(trimmed);
|
|
366
|
+
if (!params) {
|
|
367
|
+
context.report({
|
|
368
|
+
node,
|
|
369
|
+
messageId: 'useMotionEasingTokenUnknown',
|
|
370
|
+
data: {
|
|
371
|
+
value: rawValue
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const exact = EASING_TOKENS.find(t => t.value === trimmed);
|
|
377
|
+
if (exact) {
|
|
378
|
+
const suggestion = buildTokenCall(exact.token, rawValue);
|
|
379
|
+
context.report({
|
|
380
|
+
node,
|
|
381
|
+
messageId: 'useMotionEasingToken',
|
|
382
|
+
data: {
|
|
383
|
+
value: rawValue
|
|
384
|
+
},
|
|
385
|
+
suggest: [{
|
|
386
|
+
messageId: 'useMotionEasingTokenSuggest',
|
|
387
|
+
data: {
|
|
388
|
+
suggestion
|
|
389
|
+
},
|
|
390
|
+
fix(fixer) {
|
|
391
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
|
|
392
|
+
}
|
|
393
|
+
}]
|
|
394
|
+
});
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const closest = findClosestEasingToken(params);
|
|
398
|
+
if (closest) {
|
|
399
|
+
const suggestion = buildTokenCall(closest.token, rawValue);
|
|
400
|
+
context.report({
|
|
401
|
+
node,
|
|
402
|
+
messageId: 'useMotionEasingToken',
|
|
403
|
+
data: {
|
|
404
|
+
value: rawValue
|
|
405
|
+
},
|
|
406
|
+
suggest: [{
|
|
407
|
+
messageId: 'useMotionEasingTokenSuggest',
|
|
408
|
+
data: {
|
|
409
|
+
suggestion
|
|
410
|
+
},
|
|
411
|
+
fix(fixer) {
|
|
412
|
+
return [...getImportFix(fixer), fixer.replaceText(node.value, suggestion)];
|
|
413
|
+
}
|
|
414
|
+
}]
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
context.report({
|
|
418
|
+
node,
|
|
419
|
+
messageId: 'useMotionEasingTokenUnknown',
|
|
420
|
+
data: {
|
|
421
|
+
value: rawValue
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function handleProperty(node) {
|
|
427
|
+
const key = node.key;
|
|
428
|
+
if (key.type !== 'Identifier') {
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const isDuration = DURATION_PROPERTIES.has(key.name);
|
|
432
|
+
const isEasing = EASING_PROPERTIES.has(key.name);
|
|
433
|
+
if (!isDuration && !isEasing) {
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const value = node.value;
|
|
437
|
+
if (value.type === 'TemplateLiteral') {
|
|
438
|
+
// Only handle no-interpolation template literals (e.g. `200ms`) — treat as string
|
|
439
|
+
const tl = value;
|
|
440
|
+
if (tl.expressions.length === 0 && tl.quasis.length === 1) {
|
|
441
|
+
var _tl$quasis$0$value$co;
|
|
442
|
+
const rawValue = (_tl$quasis$0$value$co = tl.quasis[0].value.cooked) !== null && _tl$quasis$0$value$co !== void 0 ? _tl$quasis$0$value$co : tl.quasis[0].value.raw;
|
|
443
|
+
if (isDuration) {
|
|
444
|
+
handleDurationProperty(node, rawValue);
|
|
445
|
+
} else {
|
|
446
|
+
handleEasingProperty(node, rawValue);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
if (value.type === 'CallExpression') {
|
|
452
|
+
const ce = value;
|
|
453
|
+
if (ce.callee.type === 'Identifier' && ce.callee.name === 'token') {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
if (value.type === 'Literal') {
|
|
459
|
+
const lit = value;
|
|
460
|
+
let rawValue;
|
|
461
|
+
if (typeof lit.value === 'string') {
|
|
462
|
+
rawValue = lit.value;
|
|
463
|
+
} else if (typeof lit.value === 'number') {
|
|
464
|
+
// Treat bare numbers as ms
|
|
465
|
+
rawValue = `${lit.value}ms`;
|
|
466
|
+
} else {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (isDuration) {
|
|
470
|
+
handleDurationProperty(node, rawValue);
|
|
471
|
+
} else {
|
|
472
|
+
handleEasingProperty(node, rawValue);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
return {
|
|
477
|
+
ImportDeclaration(node) {
|
|
478
|
+
if (node.source.value === '@atlaskit/tokens') {
|
|
479
|
+
tokensImportNode = node;
|
|
480
|
+
hasTokenSpecifier = node.specifiers.some(s => s.type === 'ImportSpecifier' && s.local.name === 'token');
|
|
481
|
+
}
|
|
482
|
+
},
|
|
483
|
+
Property(node) {
|
|
484
|
+
handleProperty(node);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
export default useTokensMotion;
|