UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

548 lines (540 loc) 19.8 kB
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;