UNPKG

@atlaskit/eslint-plugin-design-system

Version:

The essential plugin for use with the Atlassian Design System.

633 lines (593 loc) 28.4 kB
import { isNodeOfType } from 'eslint-codemod-utils'; import { createLintRule } from '../utils/create-lint-rule'; import { isImportFromPackage } from '../utils/is-import-from-package'; const LOZENGE_IMPORT_SOURCES = new Set(['@atlaskit/lozenge', '@atlaskit/lozenge/lozenge']); const BADGE_IMPORT_SOURCES = new Set(['@atlaskit/badge', '@atlaskit/badge/badge']); const SIMPLE_TAG_IMPORT_SOURCES = new Set(['@atlaskit/tag/simple-tag']); const REMOVABLE_TAG_IMPORT_SOURCES = new Set(['@atlaskit/tag', '@atlaskit/tag/removable-tag']); const AVATAR_IMPORT_SOURCES = new Set(['@atlaskit/avatar', '@atlaskit/avatar/avatar', '@atlaskit/avatar/Avatar']); const 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(context) { /** * Contains a map of imported Lozenge components. */ const lozengeImports = {}; // local name -> import source /** * Contains a map of imported Badge components. */ const 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 } */ const tagImports = {}; /** * Tracks which tag imports need to migrate to Tag (default) or AvatarTag (named) * Maps local name to migration target: 'Tag' | 'AvatarTag' */ const tagMigrationTargets = {}; /** * Tracks import declaration nodes that need to be updated */ const importDeclarationsToUpdate = new Set(); /** * Contains a map of imported Avatar components from @atlaskit/avatar. * Maps local name to import source */ const 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 */ const 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') { const expr = node.expression; return expr && expr.type !== 'Literal'; } return false; } /** * Get all attributes as an object for easier manipulation */ function getAttributesMap(attributes) { const map = {}; attributes.forEach(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) { const 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) { const 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) { const 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) { const getJsxIdentifierName = node => { return (node === null || node === void 0 ? void 0 : node.type) === 'JSXIdentifier' ? node.name : null; }; if (!elemBeforeProp || !elemBeforeProp.value) { return null; } const value = elemBeforeProp.value; // Check for JSX element: <Avatar ... /> if (value.type === 'JSXElement') { const 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)) { const avatarName = getJsxIdentifierName(value.expression.openingElement.name); if (!avatarName) { return null; } if (avatarImports[avatarName]) { return avatarName; } } // Arrow function: {() => <Avatar ... />} if (value.expression.type === 'ArrowFunctionExpression') { const body = value.expression.body; if (body.type === 'JSXElement') { const avatarName = getJsxIdentifierName(body.openingElement.name); if (!avatarName) { return null; } if (avatarImports[avatarName]) { return avatarName; } } } } return null; } /** * Check if color prop value needs mapping */ function colorNeedsMapping(colorProp) { if (!(colorProp !== null && colorProp !== void 0 && colorProp.value)) { return false; } const 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 fixer => { if (!attrValue) { return null; } if (attrValue.type === 'Literal') { return fixer.replaceText(attrValue, `"${newValue}"`); } if (attrValue.type === 'JSXExpressionContainer' && 'expression' in attrValue && attrValue.expression && attrValue.expression.type === 'Literal') { return fixer.replaceText(attrValue.expression, `"${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, options = {}) { var _context$sourceCode; // @ts-ignore - Jira's ESLint v10 types expose sourceCode, platform still checks with ESLint v9. const sourceCode = (_context$sourceCode = context.sourceCode) !== null && _context$sourceCode !== void 0 ? _context$sourceCode : context.getSourceCode(); const attributes = node.openingElement.attributes; // Build new attributes array const newAttributes = []; attributes.forEach(attr => { if (attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier') { const 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; } const stringValue = extractStringValue(attr.value); if (stringValue && typeof stringValue === 'string') { const mappedColor = mapTagColorValue(stringValue); newAttributes.push(`color="${mappedColor}"`); } else { // If we can't extract the string value, keep as-is const value = attr.value ? sourceCode.getText(attr.value) : ''; newAttributes.push(`color${value ? `=${value}` : ''}`); } return; } if (attrName === 'elemBefore') { // For avatar tag, rename elemBefore to avatar and use render props if (options.isAvatarTag) { const elemBeforeValue = attr.value; let avatarElement = null; // Extract Avatar element from various formats if (elemBeforeValue.type === 'JSXElement') { avatarElement = elemBeforeValue; } else if (elemBeforeValue.type === 'JSXExpressionContainer') { const 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} ... />} const avatarElementText = sourceCode.getText(avatarElement); const avatarComponentName = avatarElement.openingElement.name.type === 'JSXIdentifier' ? avatarElement.openingElement.name.name : 'Avatar'; // Add {...props} spread to the Avatar element attributes const avatarWithProps = avatarElementText.replace(new RegExp(`<${avatarComponentName}(\\s|/>)`), `<${avatarComponentName} {...props}$1`); newAttributes.push(`avatar={(props) => ${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}'); } const attributesText = newAttributes.length > 0 ? ` ${newAttributes.join(' ')}` : ''; const children = node.children.length > 0 ? sourceCode.getText().slice(node.openingElement.range[1], node.closingElement ? node.closingElement.range[0] : node.range[1]) : ''; const componentName = options.preserveComponentName ? node.openingElement.name.name : options.isAvatarTag ? 'AvatarTag' : 'Tag'; if (node.closingElement) { return `<${componentName}${attributesText}>${children}</${componentName}>`; } else { return `<${componentName}${attributesText} />`; } } return { ImportDeclaration(node) { const moduleSource = node.source.value; if (typeof moduleSource === 'string') { // Track Lozenge imports if (LOZENGE_IMPORT_SOURCES.has(moduleSource)) { node.specifiers.forEach(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(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(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: { ...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: { ...spec, parent: node } }; importDeclarationsToUpdate.add(node); } } else if (spec.type === 'ImportSpecifier' && spec.imported.type === 'Identifier') { const importName = spec.imported.name; if (importName === 'SimpleTag' || importName === 'RemovableTag') { tagImports[spec.local.name] = { type: importName, source: moduleSource, node: { ...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(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(node) { if (!isNodeOfType(node, 'JSXElement')) { return; } if (!isNodeOfType(node.openingElement.name, 'JSXIdentifier')) { return; } const 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]) { const tagImportInfo = tagImports[elementName]; const attributesMap = getAttributesMap(node.openingElement.attributes); const { elemBefore: elemBeforeProp, avatar: avatarProp, appearance: appearanceProp, color: colorProp } = attributesMap; // 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') { const needsNameChange = false; const needsMigration = needsNameChange || appearanceProp || colorNeedsMapping(colorProp); if (!needsMigration) { // Still need to check elemBefore for Avatar if (elemBeforeProp) { const 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 const hasAvatarInElemBefore = elemBeforeProp ? getAvatarComponentName(elemBeforeProp) !== null : false; const 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: fixer => { var _tagImportInfo$node, _tagImportInfo$node$p, _tagImportInfo$node$p2, _tagImportInfo$node2, _tagImportInfo$node2$, _tagImportInfo$node2$2, _tagImportInfo$node3, _tagImportInfo$node3$, _tagImportInfo$node3$2, _tagImportInfo$node4, _tagImportInfo$node5, _tagImportInfo$node5$, _tagImportInfo$node5$2, _tagImportInfo$node6; const fixes = []; // Fix the JSX element const 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 const isSubpathImport = ((_tagImportInfo$node = tagImportInfo.node) === null || _tagImportInfo$node === void 0 ? void 0 : (_tagImportInfo$node$p = _tagImportInfo$node.parent) === null || _tagImportInfo$node$p === void 0 ? void 0 : (_tagImportInfo$node$p2 = _tagImportInfo$node$p.source) === null || _tagImportInfo$node$p2 === void 0 ? void 0 : _tagImportInfo$node$p2.value) === '@atlaskit/tag/simple-tag' || ((_tagImportInfo$node2 = tagImportInfo.node) === null || _tagImportInfo$node2 === void 0 ? void 0 : (_tagImportInfo$node2$ = _tagImportInfo$node2.parent) === null || _tagImportInfo$node2$ === void 0 ? void 0 : (_tagImportInfo$node2$2 = _tagImportInfo$node2$.source) === null || _tagImportInfo$node2$2 === void 0 ? void 0 : _tagImportInfo$node2$2.value) === '@atlaskit/tag/removable-tag'; const isMainPackageDefaultImport = ((_tagImportInfo$node3 = tagImportInfo.node) === null || _tagImportInfo$node3 === void 0 ? void 0 : (_tagImportInfo$node3$ = _tagImportInfo$node3.parent) === null || _tagImportInfo$node3$ === void 0 ? void 0 : (_tagImportInfo$node3$2 = _tagImportInfo$node3$.source) === null || _tagImportInfo$node3$2 === void 0 ? void 0 : _tagImportInfo$node3$2.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 ? void 0 : (_tagImportInfo$node5$ = _tagImportInfo$node5.parent) === null || _tagImportInfo$node5$ === void 0 ? void 0 : (_tagImportInfo$node5$2 = _tagImportInfo$node5$.source) === null || _tagImportInfo$node5$2 === void 0 ? void 0 : _tagImportInfo$node5$2.value) === '@atlaskit/tag' && ((_tagImportInfo$node6 = tagImportInfo.node) === null || _tagImportInfo$node6 === void 0 ? void 0 : _tagImportInfo$node6.type) === 'ImportSpecifier') { var _tagImportInfo$node7; const 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. const sourceCode = (_context$sourceCode2 = context.sourceCode) !== null && _context$sourceCode2 !== void 0 ? _context$sourceCode2 : context.getSourceCode(); const 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 const otherSpecifiers = importNode.specifiers.filter(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') { const importName = spec.imported.name; return importName !== 'SimpleTag' && importName !== 'RemovableTag'; } return false; }).map(spec => sourceCode.getText(spec)); let newImportText = ''; if (migrationTarget === 'Tag') { if (otherSpecifiers.length > 0) { newImportText = `import Tag, { ${otherSpecifiers.join(', ')} } from '${mainModuleSource}';`; } else { newImportText = `import Tag from '${mainModuleSource}';`; } } else if (migrationTarget === 'AvatarTag') { if (otherSpecifiers.length > 0) { newImportText = `import { AvatarTag, ${otherSpecifiers.join(', ')} } from '${mainModuleSource}';`; } else { newImportText = `import { AvatarTag } from '${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 const appearanceProp = node.openingElement.attributes.find(attr => attr.type === 'JSXAttribute' && attr.name.type === 'JSXIdentifier' && attr.name.name === 'appearance'); if (!appearanceProp || appearanceProp.type !== 'JSXAttribute') { // No appearance prop or it's a spread attribute, nothing to migrate return; } // Check if it's a dynamic expression if (isDynamicExpression(appearanceProp.value)) { context.report({ node: appearanceProp, messageId: 'dynamicBadgeAppearance' }); return; } // Extract the string value const stringValue = extractStringValue(appearanceProp.value); if (stringValue && typeof stringValue === 'string') { const mappedValue = mapBadgeToNewAppearanceValue(stringValue); if (mappedValue !== stringValue) { context.report({ node: appearanceProp, messageId: 'updateBadgeAppearance', data: { oldValue: stringValue, newValue: mappedValue }, fix: createAppearanceFixer(appearanceProp.value, mappedValue) }); } } return; } // Only process if this is a Lozenge component we've imported if (!lozengeImports[elementName]) { return; } const attributesMap = getAttributesMap(node.openingElement.attributes); const 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) { const stringValue = extractStringValue(appearanceProp.value); if (stringValue && typeof stringValue === 'string') { const mappedValue = mapToNewAppearanceValue(stringValue); if (mappedValue !== stringValue) { context.report({ node: appearanceProp, messageId: 'updateAppearance', fix: createAppearanceFixer(appearanceProp.value, mappedValue) }); } } } } }; } }); export default rule;