UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

636 lines (596 loc) 29.4 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } import { isNodeOfType } from 'eslint-codemod-utils'; import { createLintRule } from '../utils/create-lint-rule'; import { isImportFromPackage } from '../utils/is-import-from-package'; var LOZENGE_IMPORT_SOURCES = new Set(['@atlaskit/lozenge', '@atlaskit/lozenge/lozenge']); var BADGE_IMPORT_SOURCES = new Set(['@atlaskit/badge', '@atlaskit/badge/badge']); var SIMPLE_TAG_IMPORT_SOURCES = new Set(['@atlaskit/tag/simple-tag']); var REMOVABLE_TAG_IMPORT_SOURCES = new Set(['@atlaskit/tag', '@atlaskit/tag/removable-tag']); var AVATAR_IMPORT_SOURCES = new Set(['@atlaskit/avatar', '@atlaskit/avatar/avatar', '@atlaskit/avatar/Avatar']); var rule = createLintRule({ meta: { name: 'lozenge-badge-tag-labelling-system-migration', fixable: 'code', type: 'suggestion', docs: { description: 'Helps migrate Lozenge isBold prop, Badge appearance values, and SimpleTag/RemovableTag components as part of the Labelling System Phase 1 migration.', recommended: false, severity: 'warn' }, messages: { updateAppearance: 'Update appearance value to new semantic value.', migrateTag: '<SimpleTag> and <RemovableTag> components should migrate to the new <Tag> or <AvatarTag> component.', updateBadgeAppearance: 'Update Badge appearance value "{{oldValue}}" to new semantic value "{{newValue}}".', dynamicBadgeAppearance: 'Dynamic appearance prop values require manual review to ensure they use the new semantic values: neutral, information, inverse, danger, success.' } }, create: function create(context) { /** * Contains a map of imported Lozenge components. */ var lozengeImports = {}; // local name -> import source /** * Contains a map of imported Badge components. */ var badgeImports = {}; // local name -> import source /** * Contains a map of imported Tag components (SimpleTag, RemovableTag, or default Tag imports). * Maps local name to { type: 'SimpleTag' | 'RemovableTag' | 'Tag', source: string, node: ImportNode } */ var tagImports = {}; /** * Tracks which tag imports need to migrate to Tag (default) or AvatarTag (named) * Maps local name to migration target: 'Tag' | 'AvatarTag' */ var tagMigrationTargets = {}; /** * Tracks import declaration nodes that need to be updated */ var importDeclarationsToUpdate = new Set(); /** * Contains a map of imported Avatar components from @atlaskit/avatar. * Maps local name to import source */ var avatarImports = {}; /** * Contains a map of imported Tag and AvatarTag components from @atlaskit/tag. * These are the new components that should not be migrated. * Maps local name to import source */ var newTagImports = {}; /** * Check if a JSX attribute value is dynamic (not a static literal value) * Can be used for any prop type (boolean, string, etc.) */ function isDynamicExpression(node) { if (!node) { return false; } // If it's a plain literal (e.g., appearance="value"), it's not dynamic if (node.type === 'Literal') { return false; } // If it's an expression container with a non-literal expression, it's dynamic if (node.type === 'JSXExpressionContainer') { var expr = node.expression; return expr && expr.type !== 'Literal'; } return false; } /** * Get all attributes as an object for easier manipulation */ function getAttributesMap(attributes) { var map = {}; attributes.forEach(function (attr) { if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') { map[attr.name.name] = attr; } }); return map; } /** * Map old Lozenge appearance values to new semantic appearance values. * The new Lozenge no longer uses legacy values — it uses semantic color names * that align with the new labelling system. */ function mapToNewAppearanceValue(oldValue) { var mapping = { default: 'neutral', inprogress: 'information', moved: 'warning', removed: 'danger', new: 'discovery', success: 'success' }; return mapping[oldValue] || oldValue; } /** * Map Badge old appearance values to new semantic appearance values */ function mapBadgeToNewAppearanceValue(oldValue) { var mapping = { added: 'success', removed: 'danger', default: 'neutral', primary: 'information', primaryInverted: 'inverse', important: 'danger' }; return mapping[oldValue] || oldValue; } /** * Map Tag color light variants to semantic color values */ function mapTagColorValue(oldValue) { var mapping = { limeLight: 'lime', orangeLight: 'orange', magentaLight: 'magenta', greenLight: 'green', blueLight: 'blue', redLight: 'red', purpleLight: 'purple', greyLight: 'gray', tealLight: 'teal', yellowLight: 'yellow', grey: 'gray' }; return mapping[oldValue] || oldValue; } /** * Check if elemBefore prop contains only an Avatar component from @atlaskit/avatar * Returns the Avatar component name if it's from the avatar package, null otherwise */ function getAvatarComponentName(elemBeforeProp) { var getJsxIdentifierName = function getJsxIdentifierName(node) { return (node === null || node === void 0 ? void 0 : node.type) === 'JSXIdentifier' ? node.name : null; }; if (!elemBeforeProp || !elemBeforeProp.value) { return null; } var value = elemBeforeProp.value; // Check for JSX element: <Avatar ... /> if (value.type === 'JSXElement') { var avatarName = getJsxIdentifierName(value.openingElement.name); if (!avatarName) { return null; } if (avatarImports[avatarName]) { return avatarName; } } // Check for JSX expression container: {<Avatar ... />} if (value.type === 'JSXExpressionContainer' && value.expression) { // Direct JSX element: {<Avatar ... />} if (value.expression.type === 'JSXElement' && getJsxIdentifierName(value.expression.openingElement.name)) { var _avatarName = getJsxIdentifierName(value.expression.openingElement.name); if (!_avatarName) { return null; } if (avatarImports[_avatarName]) { return _avatarName; } } // Arrow function: {() => <Avatar ... />} if (value.expression.type === 'ArrowFunctionExpression') { var body = value.expression.body; if (body.type === 'JSXElement') { var _avatarName2 = getJsxIdentifierName(body.openingElement.name); if (!_avatarName2) { return null; } if (avatarImports[_avatarName2]) { return _avatarName2; } } } } return null; } /** * Check if color prop value needs mapping */ function colorNeedsMapping(colorProp) { if (!(colorProp !== null && colorProp !== void 0 && colorProp.value)) { return false; } var stringValue = extractStringValue(colorProp.value); return stringValue !== null && typeof stringValue === 'string' && mapTagColorValue(stringValue) !== stringValue; } /** * Extract the string value from a JSX attribute value */ function extractStringValue(attrValue) { if (!attrValue) { return null; } if (attrValue.type === 'Literal') { return attrValue.value; } if (attrValue.type === 'JSXExpressionContainer' && attrValue.expression && attrValue.expression.type === 'Literal') { return attrValue.expression.value; } return null; } /** * Create a fixer function to replace an appearance prop value * Handles both Literal and JSXExpressionContainer with Literal */ function createAppearanceFixer(attrValue, newValue) { return function (fixer) { if (!attrValue) { return null; } if (attrValue.type === 'Literal') { return fixer.replaceText(attrValue, "\"".concat(newValue, "\"")); } if (attrValue.type === 'JSXExpressionContainer' && 'expression' in attrValue && attrValue.expression && attrValue.expression.type === 'Literal') { return fixer.replaceText(attrValue.expression, "\"".concat(newValue, "\"")); } return null; }; } /** * Generate the replacement JSX element text for Tag migration * Handles both regular Tag and AvatarTag migrations for SimpleTag/RemovableTag. */ function generateTagReplacement(node) { var _context$sourceCode; var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; // @ts-ignore - Jira's ESLint v10 types expose sourceCode, platform still checks with ESLint v9. var sourceCode = (_context$sourceCode = context.sourceCode) !== null && _context$sourceCode !== void 0 ? _context$sourceCode : context.getSourceCode(); var attributes = node.openingElement.attributes; // Build new attributes array var newAttributes = []; attributes.forEach(function (attr) { if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') { var attrName = attr.name.name; if (attrName === 'isBold') { // Skip isBold attribute return; } if (attrName === 'appearance') { // Delete appearance prop — not used in new Tag/AvatarTag API return; } if (attrName === 'color') { // For avatar tag, skip color prop; for regular tag, map color value // Note: Lozenge doesn't have a color prop, but Tag/SimpleTag/RemovableTag do if (options.isAvatarTag) { return; } var stringValue = extractStringValue(attr.value); if (stringValue && typeof stringValue === 'string') { var mappedColor = mapTagColorValue(stringValue); newAttributes.push("color=\"".concat(mappedColor, "\"")); } else { // If we can't extract the string value, keep as-is var value = attr.value ? sourceCode.getText(attr.value) : ''; newAttributes.push("color".concat(value ? "=".concat(value) : '')); } return; } if (attrName === 'elemBefore') { // For avatar tag, rename elemBefore to avatar and use render props if (options.isAvatarTag) { var elemBeforeValue = attr.value; var avatarElement = null; // Extract Avatar element from various formats if (elemBeforeValue.type === 'JSXElement') { avatarElement = elemBeforeValue; } else if (elemBeforeValue.type === 'JSXExpressionContainer') { var expr = elemBeforeValue.expression; // Direct JSX element: {<Avatar ... />} if (expr.type === 'JSXElement') { avatarElement = expr; } // Arrow function: {() => <Avatar ... />} else if (expr.type === 'ArrowFunctionExpression' && expr.body.type === 'JSXElement') { avatarElement = expr.body; } } if (avatarElement) { // Generate render props: avatar={(props) => <Avatar {...props} ... />} var avatarElementText = sourceCode.getText(avatarElement); var avatarComponentName = avatarElement.openingElement.name.type === 'JSXIdentifier' ? avatarElement.openingElement.name.name : 'Avatar'; // Add {...props} spread to the Avatar element attributes var avatarWithProps = avatarElementText.replace(new RegExp("<".concat(avatarComponentName, "(\\s|/>)")), "<".concat(avatarComponentName, " {...props}$1")); newAttributes.push("avatar={(props) => ".concat(avatarWithProps, "}")); } return; } // For regular tag, keep elemBefore as-is newAttributes.push(sourceCode.getText(attr)); return; } // Keep all other attributes newAttributes.push(sourceCode.getText(attr)); } else if (attr.type === 'JSXSpreadAttribute') { // Keep spread attributes newAttributes.push(sourceCode.getText(attr)); } }); // Add isRemovable={false} for SimpleTag migrations if (options.isSimpleTag) { newAttributes.push('isRemovable={false}'); } var attributesText = newAttributes.length > 0 ? " ".concat(newAttributes.join(' ')) : ''; var children = node.children.length > 0 ? sourceCode.getText().slice(node.openingElement.range[1], node.closingElement ? node.closingElement.range[0] : node.range[1]) : ''; var componentName = options.preserveComponentName ? node.openingElement.name.name : options.isAvatarTag ? 'AvatarTag' : 'Tag'; if (node.closingElement) { return "<".concat(componentName).concat(attributesText, ">").concat(children, "</").concat(componentName, ">"); } else { return "<".concat(componentName).concat(attributesText, " />"); } } return { ImportDeclaration: function ImportDeclaration(node) { var moduleSource = node.source.value; if (typeof moduleSource === 'string') { // Track Lozenge imports if (LOZENGE_IMPORT_SOURCES.has(moduleSource)) { node.specifiers.forEach(function (spec) { if (spec.type === 'ImportDefaultSpecifier') { lozengeImports[spec.local.name] = moduleSource; } else if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { if (spec.imported.name === 'Lozenge') { lozengeImports[spec.local.name] = moduleSource; } } }); } // Track Badge imports if (BADGE_IMPORT_SOURCES.has(moduleSource)) { node.specifiers.forEach(function (spec) { if (spec.type === 'ImportDefaultSpecifier') { badgeImports[spec.local.name] = moduleSource; } else if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { if (spec.imported.name === 'Badge' || spec.imported.name === 'default') { badgeImports[spec.local.name] = moduleSource; } } }); } // Track Tag imports (SimpleTag, RemovableTag only - not the new Tag component) if (isImportFromPackage(moduleSource, '@atlaskit/tag')) { node.specifiers.forEach(function (spec) { if (spec.type === 'ImportDefaultSpecifier') { // Check for default imports from subpaths and main package if (SIMPLE_TAG_IMPORT_SOURCES.has(moduleSource)) { // Default import from @atlaskit/tag/simple-tag is a SimpleTag tagImports[spec.local.name] = { type: 'SimpleTag', source: moduleSource, node: _objectSpread(_objectSpread({}, spec), {}, { parent: node }) }; importDeclarationsToUpdate.add(node); } else if (REMOVABLE_TAG_IMPORT_SOURCES.has(moduleSource)) { // Default import from @atlaskit/tag/removable-tag or @atlaskit/tag is a RemovableTag tagImports[spec.local.name] = { type: 'RemovableTag', source: moduleSource, node: _objectSpread(_objectSpread({}, spec), {}, { parent: node }) }; importDeclarationsToUpdate.add(node); } } else if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { var importName = spec.imported.name; if (importName === 'SimpleTag' || importName === 'RemovableTag') { tagImports[spec.local.name] = { type: importName, source: moduleSource, node: _objectSpread(_objectSpread({}, spec), {}, { parent: node }) }; // Mark this import declaration for potential updates importDeclarationsToUpdate.add(node); } else if (importName === 'AvatarTag') { // Track new AvatarTag component - it should not be migrated newTagImports[spec.local.name] = moduleSource; } // Note: Tag from named imports is not skipped - it may still need migration // (e.g., if it has appearance prop or other old props) } }); } // Track Avatar imports if (AVATAR_IMPORT_SOURCES.has(moduleSource)) { node.specifiers.forEach(function (spec) { if (spec.type === 'ImportDefaultSpecifier') { avatarImports[spec.local.name] = moduleSource; } else if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { if (spec.imported.name === 'Avatar') { avatarImports[spec.local.name] = moduleSource; } } }); } } }, JSXElement: function JSXElement(node) { if (!isNodeOfType(node, 'JSXElement')) { return; } if (!isNodeOfType(node.openingElement.name, 'JSXIdentifier')) { return; } var elementName = node.openingElement.name.name; // Skip new AvatarTag component - it should not be migrated if (newTagImports[elementName]) { return; } // Handle SimpleTag, RemovableTag, and Tag migrations if (tagImports[elementName]) { var tagImportInfo = tagImports[elementName]; var _attributesMap = getAttributesMap(node.openingElement.attributes); var elemBeforeProp = _attributesMap.elemBefore, avatarProp = _attributesMap.avatar, _appearanceProp = _attributesMap.appearance, colorProp = _attributesMap.color; // For default import from @atlaskit/tag, check if it's already the new Tag if (tagImportInfo.type === 'RemovableTag' && tagImportInfo.source === '@atlaskit/tag') { // If using avatar prop, it's already the new Tag if (avatarProp) { return; } // Check if component name is already correct and nothing needs migration if (elementName === 'Tag' || elementName === 'AvatarTag') { var needsNameChange = false; var needsMigration = needsNameChange || _appearanceProp || colorNeedsMapping(colorProp); if (!needsMigration) { // Still need to check elemBefore for Avatar if (elemBeforeProp) { var _hasAvatarInElemBefore = getAvatarComponentName(elemBeforeProp) !== null; if (_hasAvatarInElemBefore) { // Has Avatar in elemBefore, needs migration to AvatarTag } else { // No Avatar, nothing to migrate return; } } else { // No elemBefore, nothing to migrate return; } } // If we get here, something needs migration } } // Determine migration target based on elemBefore containing Avatar var hasAvatarInElemBefore = elemBeforeProp ? getAvatarComponentName(elemBeforeProp) !== null : false; var migrationTarget = hasAvatarInElemBefore ? 'AvatarTag' : 'Tag'; // Record the migration target for this import tagMigrationTargets[elementName] = migrationTarget; // Migrate the JSX element context.report({ node: node, messageId: 'migrateTag', fix: function fix(fixer) { var _tagImportInfo$node, _tagImportInfo$node2, _tagImportInfo$node3, _tagImportInfo$node4, _tagImportInfo$node5, _tagImportInfo$node6; var fixes = []; // Fix the JSX element var replacement = generateTagReplacement(node, { isAvatarTag: hasAvatarInElemBefore, isSimpleTag: !hasAvatarInElemBefore && tagImportInfo.type === 'SimpleTag' }); fixes.push(fixer.replaceText(node, replacement)); // Fix the import statement for named imports, subpath default imports, and main package default imports var isSubpathImport = ((_tagImportInfo$node = tagImportInfo.node) === null || _tagImportInfo$node === void 0 || (_tagImportInfo$node = _tagImportInfo$node.parent) === null || _tagImportInfo$node === void 0 || (_tagImportInfo$node = _tagImportInfo$node.source) === null || _tagImportInfo$node === void 0 ? void 0 : _tagImportInfo$node.value) === '@atlaskit/tag/simple-tag' || ((_tagImportInfo$node2 = tagImportInfo.node) === null || _tagImportInfo$node2 === void 0 || (_tagImportInfo$node2 = _tagImportInfo$node2.parent) === null || _tagImportInfo$node2 === void 0 || (_tagImportInfo$node2 = _tagImportInfo$node2.source) === null || _tagImportInfo$node2 === void 0 ? void 0 : _tagImportInfo$node2.value) === '@atlaskit/tag/removable-tag'; var isMainPackageDefaultImport = ((_tagImportInfo$node3 = tagImportInfo.node) === null || _tagImportInfo$node3 === void 0 || (_tagImportInfo$node3 = _tagImportInfo$node3.parent) === null || _tagImportInfo$node3 === void 0 || (_tagImportInfo$node3 = _tagImportInfo$node3.source) === null || _tagImportInfo$node3 === void 0 ? void 0 : _tagImportInfo$node3.value) === '@atlaskit/tag' && ((_tagImportInfo$node4 = tagImportInfo.node) === null || _tagImportInfo$node4 === void 0 ? void 0 : _tagImportInfo$node4.type) === 'ImportDefaultSpecifier'; if (isSubpathImport || isMainPackageDefaultImport || ((_tagImportInfo$node5 = tagImportInfo.node) === null || _tagImportInfo$node5 === void 0 || (_tagImportInfo$node5 = _tagImportInfo$node5.parent) === null || _tagImportInfo$node5 === void 0 || (_tagImportInfo$node5 = _tagImportInfo$node5.source) === null || _tagImportInfo$node5 === void 0 ? void 0 : _tagImportInfo$node5.value) === '@atlaskit/tag' && ((_tagImportInfo$node6 = tagImportInfo.node) === null || _tagImportInfo$node6 === void 0 ? void 0 : _tagImportInfo$node6.type) === 'ImportSpecifier') { var _tagImportInfo$node7; var importNode = (_tagImportInfo$node7 = tagImportInfo.node) === null || _tagImportInfo$node7 === void 0 ? void 0 : _tagImportInfo$node7.parent; if (importNode) { var _context$sourceCode2; // @ts-ignore - Jira's ESLint v10 types expose sourceCode, platform still checks with ESLint v9. var sourceCode = (_context$sourceCode2 = context.sourceCode) !== null && _context$sourceCode2 !== void 0 ? _context$sourceCode2 : context.getSourceCode(); var mainModuleSource = '@atlaskit/tag'; // Get all other specifiers that are not SimpleTag or RemovableTag // For subpath imports and main package default imports, exclude the default specifier itself var otherSpecifiers = importNode.specifiers.filter(function (spec) { // Skip default specifiers from subpath imports and main package - they're being replaced if (spec.type === 'ImportDefaultSpecifier' && (isSubpathImport || isMainPackageDefaultImport)) { return false; } if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { var importName = spec.imported.name; return importName !== 'SimpleTag' && importName !== 'RemovableTag'; } return false; }).map(function (spec) { return sourceCode.getText(spec); }); var newImportText = ''; if (migrationTarget === 'Tag') { if (otherSpecifiers.length > 0) { newImportText = "import Tag, { ".concat(otherSpecifiers.join(', '), " } from '").concat(mainModuleSource, "';"); } else { newImportText = "import Tag from '".concat(mainModuleSource, "';"); } } else if (migrationTarget === 'AvatarTag') { if (otherSpecifiers.length > 0) { newImportText = "import { AvatarTag, ".concat(otherSpecifiers.join(', '), " } from '").concat(mainModuleSource, "';"); } else { newImportText = "import { AvatarTag } from '".concat(mainModuleSource, "';"); } } if (newImportText) { fixes.push(fixer.replaceText(importNode, newImportText)); } } } return fixes.length === 1 ? fixes[0] : fixes; } }); return; } // Handle Badge components if (badgeImports[elementName]) { // Find the appearance prop var _appearanceProp2 = node.openingElement.attributes.find(function (attr) { return attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'appearance'; }); if (!_appearanceProp2 || _appearanceProp2.type !== 'JSXAttribute') { // No appearance prop or it's a spread attribute, nothing to migrate return; } // Check if it's a dynamic expression if (isDynamicExpression(_appearanceProp2.value)) { context.report({ node: _appearanceProp2, messageId: 'dynamicBadgeAppearance' }); return; } // Extract the string value var stringValue = extractStringValue(_appearanceProp2.value); if (stringValue && typeof stringValue === 'string') { var mappedValue = mapBadgeToNewAppearanceValue(stringValue); if (mappedValue !== stringValue) { context.report({ node: _appearanceProp2, messageId: 'updateBadgeAppearance', data: { oldValue: stringValue, newValue: mappedValue }, fix: createAppearanceFixer(_appearanceProp2.value, mappedValue) }); } } return; } // Only process if this is a Lozenge component we've imported if (!lozengeImports[elementName]) { return; } var attributesMap = getAttributesMap(node.openingElement.attributes); var appearanceProp = attributesMap.appearance; // Handle appearance prop value migration — always update to new semantic values. // isBold is intentionally not flagged: users may still need it while the feature flag // platform-dst-lozenge-tag-badge-visual-uplifts is OFF (subtle variant still rendered). if (appearanceProp) { var _stringValue = extractStringValue(appearanceProp.value); if (_stringValue && typeof _stringValue === 'string') { var _mappedValue = mapToNewAppearanceValue(_stringValue); if (_mappedValue !== _stringValue) { context.report({ node: appearanceProp, messageId: 'updateAppearance', fix: createAppearanceFixer(appearanceProp.value, _mappedValue) }); } } } } }; } }); export default rule;