@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
633 lines (593 loc) • 28.4 kB
JavaScript
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;