@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
548 lines (540 loc) • 19.8 kB
JavaScript
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
import tokenDefaultValues from '@atlaskit/tokens/token-default-values';
import { createLintRule } from '../utils/create-lint-rule';
var 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'];
function parseDurationMs(value) {
var ms = value.match(/^(\d+(?:\.\d+)?)ms$/);
if (ms) {
return parseFloat(ms[1]);
}
var s = value.match(/^(\d+(?:\.\d+)?)s$/);
if (s) {
return parseFloat(s[1]) * 1000;
}
return null;
}
var DURATION_TOKENS = DURATION_TOKEN_NAMES.map(function (name) {
var rawValue = tokenDefaultValues[name];
var ms = parseDurationMs(rawValue);
if (ms === null) {
throw new Error("use-tokens-motion: could not parse duration for token ".concat(name, ": ").concat(rawValue));
}
return {
ms: ms,
token: name
};
}).sort(function (a, b) {
return a.ms - b.ms;
});
var EASING_TOKEN_NAMES = ['motion.easing.in.practical', 'motion.easing.inout.bold', 'motion.easing.out.practical', 'motion.easing.out.bold'];
function parseCubicBezierParams(value) {
var match = value.match(/^cubic-bezier\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)$/);
if (!match) {
return null;
}
return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])];
}
var EASING_TOKENS = EASING_TOKEN_NAMES.map(function (name) {
var rawValue = tokenDefaultValues[name];
var params = parseCubicBezierParams(rawValue);
if (!params) {
throw new Error("use-tokens-motion: could not parse cubic-bezier for token ".concat(name, ": ").concat(rawValue));
}
return {
value: rawValue,
token: name,
params: params
};
});
// Splits on top-level commas (outside function parens) — preserves cubic-bezier(...) commas.
function splitOnTopLevelCommas(value) {
var parts = [];
var depth = 0;
var current = '';
var _iterator = _createForOfIteratorHelper(value),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var ch = _step.value;
if (ch === '(') {
depth++;
current += ch;
} else if (ch === ')') {
depth--;
current += ch;
} else if (ch === ',' && depth === 0) {
parts.push(current.trim());
current = '';
} else {
current += ch;
}
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
if (current.trim().length > 0) {
parts.push(current.trim());
}
return parts;
}
var DURATION_PROPERTIES = new Set(['transitionDuration', 'animationDuration']);
var EASING_PROPERTIES = new Set(['transitionTimingFunction', 'animationTimingFunction']);
// Explicit semantic mappings for CSS keyword easings to motion tokens.
// Pinned by design intent, confirmed with design system team (Alex + Akshay).
var CSS_KEYWORD_EASING_TOKEN_MAP = {
ease: 'motion.easing.out.practical',
'ease-out': 'motion.easing.out.practical',
'ease-in': 'motion.easing.in.practical',
'ease-in-out': 'motion.easing.inout.bold'
// linear (0,0,1,1) — warn only, no autofix (per Akshay: too generic, no good token match)
};
// Non-curve easing values with no meaningful cubic-bezier representation — skip entirely
var SKIP_EASING_VALUES = new Set(['step-start', 'step-end', 'inherit', 'initial', 'unset', 'none']);
function euclideanDistance(a, b) {
return Math.sqrt(a.reduce(function (sum, val, i) {
return sum + Math.pow(val - b[i], 2);
}, 0));
}
// Maximum Euclidean distance for easing autofix — beyond this threshold, we report-only
var EASING_AUTOFIX_THRESHOLD = 0.5;
function findClosestEasingToken(params) {
var minDist = Infinity;
var closest = EASING_TOKENS[0];
var _iterator2 = _createForOfIteratorHelper(EASING_TOKENS),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var entry = _step2.value;
var dist = euclideanDistance(params, entry.params);
if (dist < minDist) {
minDist = dist;
closest = entry;
}
}
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
if (minDist > EASING_AUTOFIX_THRESHOLD) {
return null;
}
return {
token: closest.token,
value: closest.value,
dist: minDist
};
}
function findClosestDurationTokens(ms) {
var exact = DURATION_TOKENS.find(function (t) {
return t.ms === ms;
});
if (exact) {
return [exact];
}
var minDist = Infinity;
var _iterator3 = _createForOfIteratorHelper(DURATION_TOKENS),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var entry = _step3.value;
var dist = Math.abs(entry.ms - ms);
if (dist < minDist) {
minDist = dist;
}
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
var closest = DURATION_TOKENS.filter(function (t) {
return Math.abs(t.ms - ms) === minDist;
});
return closest;
}
var useTokensMotion = createLintRule({
meta: {
name: 'use-tokens-motion',
type: 'suggestion',
hasSuggestions: true,
docs: {
description: 'Enforces usage of motion design tokens rather than hard-coded duration and easing values.',
recommended: false,
severity: 'warn'
},
messages: {
useMotionDurationToken: "Use a motion duration token instead of the hard-coded value '{{ value }}'.",
useMotionDurationTokenSuggest: 'Replace with {{ suggestion }}.',
useMotionDurationTokenNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion1 }} or {{ suggestion2 }}.",
useMotionDurationTokenSingleNearest: "No exact token match for '{{ value }}'. Nearest: {{ suggestion }}.",
useMotionEasingToken: "Use a motion easing token instead of the hard-coded value '{{ value }}'.",
useMotionEasingTokenSuggest: 'Replace with {{ suggestion }}.',
useMotionEasingTokenUnknown: "Use a motion easing token from @atlaskit/tokens instead of the hard-coded value '{{ value }}'."
}
},
create: function create(context) {
var tokensImportNode = null;
var hasTokenSpecifier = false;
function buildTokenCall(tokenName, fallback) {
return "token('".concat(tokenName, "', '").concat(fallback, "')");
}
function getImportFix(fixer) {
var _context$sourceCode;
if (hasTokenSpecifier) {
return [];
}
if (tokensImportNode) {
// @atlaskit/tokens is imported but without `token` — add `token` to existing import
var lastSpecifier = tokensImportNode.specifiers[tokensImportNode.specifiers.length - 1];
if (lastSpecifier) {
return [fixer.insertTextAfter(lastSpecifier, ', token')];
}
// Empty import — replace the whole declaration
return [fixer.replaceText(tokensImportNode, "import { token } from '@atlaskit/tokens';")];
}
var sourceCode = (_context$sourceCode = context.sourceCode) !== null && _context$sourceCode !== void 0 ? _context$sourceCode : context.getSourceCode();
var programBody = sourceCode.ast.body;
// Insert after the last existing import, or at top if no imports exist
var lastImport = _toConsumableArray(programBody).reverse().find(function (n) {
return n.type === 'ImportDeclaration';
});
if (lastImport) {
return [fixer.insertTextAfter(lastImport, "\nimport { token } from '@atlaskit/tokens';")];
}
if (programBody.length > 0) {
return [fixer.insertTextBefore(programBody[0], "import { token } from '@atlaskit/tokens';\n")];
}
return [];
}
// Returns autofix string for a single duration value, or null if ambiguous (equidistant)
function resolveDurationToken(value) {
var ms = parseDurationMs(value);
if (ms === null) {
return null;
}
var exact = DURATION_TOKENS.find(function (t) {
return t.ms === ms;
});
if (exact) {
return buildTokenCall(exact.token, value);
}
return null;
}
function handleDurationProperty(node, rawValue) {
var segments = splitOnTopLevelCommas(rawValue);
if (segments.length === 1) {
var ms = parseDurationMs(rawValue);
if (ms === null) {
return;
}
var exactMatch = DURATION_TOKENS.find(function (t) {
return t.ms === ms;
});
if (exactMatch) {
var suggestion = buildTokenCall(exactMatch.token, rawValue);
context.report({
node: node,
messageId: 'useMotionDurationToken',
data: {
value: rawValue
},
suggest: [{
messageId: 'useMotionDurationTokenSuggest',
data: {
suggestion: suggestion
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, suggestion)]);
}
}]
});
} else {
var result = findClosestDurationTokens(ms);
if (result.length >= 2) {
var suggestion1 = buildTokenCall(result[0].token, rawValue);
var suggestion2 = buildTokenCall(result[1].token, rawValue);
context.report({
node: node,
messageId: 'useMotionDurationTokenNearest',
data: {
value: rawValue,
suggestion1: "".concat(suggestion1, " (").concat(result[0].ms, "ms)"),
suggestion2: "".concat(suggestion2, " (").concat(result[1].ms, "ms)")
}
});
} else {
var _suggestion = buildTokenCall(result[0].token, rawValue);
context.report({
node: node,
messageId: 'useMotionDurationTokenSingleNearest',
data: {
value: rawValue,
suggestion: "".concat(_suggestion, " (").concat(result[0].ms, "ms)")
}
});
}
}
return;
}
var resolved = segments.map(resolveDurationToken);
if (resolved.some(function (s) {
return s === null;
})) {
return;
}
var templateLiteral = '`' + resolved.map(function (s) {
return "${".concat(s, "}");
}).join(', ') + '`';
context.report({
node: node,
messageId: 'useMotionDurationToken',
data: {
value: rawValue
},
suggest: [{
messageId: 'useMotionDurationTokenSuggest',
data: {
suggestion: templateLiteral
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, templateLiteral)]);
}
}]
});
}
// Returns autofix string for a single easing value, or null if no token suggestion is possible
function resolveEasingToken(value) {
var trimmed = value.trim();
if (SKIP_EASING_VALUES.has(trimmed)) {
return null;
}
if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
return buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
}
// linear has no curve (0,0,1,1) — warn only, no autofix
if (trimmed === 'linear') {
return null;
}
if (trimmed.startsWith('linear(')) {
// linear() is used for spring animations — motion.easing.spring is experimental, skip
return null;
}
var params = parseCubicBezierParams(trimmed);
if (!params) {
return null;
}
var exact = EASING_TOKENS.find(function (t) {
return t.value === trimmed;
});
if (exact) {
return buildTokenCall(exact.token, trimmed);
}
var closest = findClosestEasingToken(params);
return closest ? buildTokenCall(closest.token, trimmed) : null;
}
function handleEasingProperty(node, rawValue) {
var segments = splitOnTopLevelCommas(rawValue);
// Multi-value path: resolve each segment, autofix only if all resolve cleanly
if (segments.length > 1) {
var resolved = segments.map(resolveEasingToken);
if (resolved.some(function (s) {
return s === null;
})) {
return;
}
var templateLiteral = '`' + resolved.map(function (s) {
return "${".concat(s, "}");
}).join(', ') + '`';
context.report({
node: node,
messageId: 'useMotionEasingToken',
data: {
value: rawValue
},
suggest: [{
messageId: 'useMotionEasingTokenSuggest',
data: {
suggestion: templateLiteral
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, templateLiteral)]);
}
}]
});
return;
}
var trimmed = rawValue.trim();
if (SKIP_EASING_VALUES.has(trimmed)) {
return;
}
// CSS keyword easings: convert to cubic-bezier equivalent and find closest token
if (trimmed in CSS_KEYWORD_EASING_TOKEN_MAP) {
var suggestion = buildTokenCall(CSS_KEYWORD_EASING_TOKEN_MAP[trimmed], trimmed);
context.report({
node: node,
messageId: 'useMotionEasingToken',
data: {
value: trimmed
},
suggest: [{
messageId: 'useMotionEasingTokenSuggest',
data: {
suggestion: suggestion
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, suggestion)]);
}
}]
});
return;
}
// linear has no curve (0,0,1,1) — warn only, no autofix
if (trimmed === 'linear') {
context.report({
node: node,
messageId: 'useMotionEasingTokenUnknown',
data: {
value: trimmed
}
});
return;
}
if (trimmed.startsWith('linear(')) {
// linear() is used for spring animations — motion.easing.spring is experimental, skip
return;
}
var params = parseCubicBezierParams(trimmed);
if (!params) {
context.report({
node: node,
messageId: 'useMotionEasingTokenUnknown',
data: {
value: rawValue
}
});
return;
}
var exact = EASING_TOKENS.find(function (t) {
return t.value === trimmed;
});
if (exact) {
var _suggestion2 = buildTokenCall(exact.token, rawValue);
context.report({
node: node,
messageId: 'useMotionEasingToken',
data: {
value: rawValue
},
suggest: [{
messageId: 'useMotionEasingTokenSuggest',
data: {
suggestion: _suggestion2
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, _suggestion2)]);
}
}]
});
return;
}
var closest = findClosestEasingToken(params);
if (closest) {
var _suggestion3 = buildTokenCall(closest.token, rawValue);
context.report({
node: node,
messageId: 'useMotionEasingToken',
data: {
value: rawValue
},
suggest: [{
messageId: 'useMotionEasingTokenSuggest',
data: {
suggestion: _suggestion3
},
fix: function fix(fixer) {
return [].concat(_toConsumableArray(getImportFix(fixer)), [fixer.replaceText(node.value, _suggestion3)]);
}
}]
});
} else {
context.report({
node: node,
messageId: 'useMotionEasingTokenUnknown',
data: {
value: rawValue
}
});
}
}
function handleProperty(node) {
var key = node.key;
if (key.type !== 'Identifier') {
return;
}
var isDuration = DURATION_PROPERTIES.has(key.name);
var isEasing = EASING_PROPERTIES.has(key.name);
if (!isDuration && !isEasing) {
return;
}
var value = node.value;
if (value.type === 'TemplateLiteral') {
// Only handle no-interpolation template literals (e.g. `200ms`) — treat as string
var tl = value;
if (tl.expressions.length === 0 && tl.quasis.length === 1) {
var _tl$quasis$0$value$co;
var 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;
if (isDuration) {
handleDurationProperty(node, rawValue);
} else {
handleEasingProperty(node, rawValue);
}
}
return;
}
if (value.type === 'CallExpression') {
var ce = value;
if (ce.callee.type === 'Identifier' && ce.callee.name === 'token') {
return;
}
return;
}
if (value.type === 'Literal') {
var lit = value;
var _rawValue;
if (typeof lit.value === 'string') {
_rawValue = lit.value;
} else if (typeof lit.value === 'number') {
// Treat bare numbers as ms
_rawValue = "".concat(lit.value, "ms");
} else {
return;
}
if (isDuration) {
handleDurationProperty(node, _rawValue);
} else {
handleEasingProperty(node, _rawValue);
}
}
}
return {
ImportDeclaration: function ImportDeclaration(node) {
if (node.source.value === '@atlaskit/tokens') {
tokensImportNode = node;
hasTokenSpecifier = node.specifiers.some(function (s) {
return s.type === 'ImportSpecifier' && s.local.name === 'token';
});
}
},
Property: function Property(node) {
handleProperty(node);
}
};
}
});
export default useTokensMotion;