diff --git a/packages/blade/codemods/brand-refresh/transformers/__tests__/migrate-typography.test.ts b/packages/blade/codemods/brand-refresh/transformers/__tests__/migrate-typography.test.ts index a0a9086a0e5..59e64082a25 100644 --- a/packages/blade/codemods/brand-refresh/transformers/__tests__/migrate-typography.test.ts +++ b/packages/blade/codemods/brand-refresh/transformers/__tests__/migrate-typography.test.ts @@ -1,6 +1,7 @@ import path from 'path'; import { applyTransform } from '@hypermod/utils'; import * as transformer from '..'; +import { red } from '../utils'; it('should update the lineHeight & fontSize tokens', async () => { const result = await applyTransform( @@ -307,18 +308,18 @@ it('should correctly convert Title to Heading component', async () => { expect(consoleSpy).toHaveBeenNthCalledWith( 1, - transformer.red('\n⛔️ Expression found in the "size" attribute, please update manually:'), - transformer.red(`${path.resolve(__dirname, __filename)}:11:10\n`), + red('\n⛔️ Expression found in the "size" attribute, please update manually:'), + red(`${path.resolve(__dirname, __filename)}:11:10\n`), ); expect(consoleSpy).toHaveBeenNthCalledWith( 2, - transformer.red('\n⛔️ Expression found in the "size" attribute, please update manually:'), - transformer.red(`${path.resolve(__dirname, __filename)}:18:10\n`), + red('\n⛔️ Expression found in the "size" attribute, please update manually:'), + red(`${path.resolve(__dirname, __filename)}:18:10\n`), ); expect(consoleSpy).toHaveBeenNthCalledWith( 3, - transformer.red('\n⛔️ Expression found in the "size" attribute, please update manually:'), - transformer.red(`${path.resolve(__dirname, __filename)}:18:84\n`), + red('\n⛔️ Expression found in the "size" attribute, please update manually:'), + red(`${path.resolve(__dirname, __filename)}:18:84\n`), ); expect(result).toMatchInlineSnapshot(` diff --git a/packages/blade/codemods/brand-refresh/transformers/index.ts b/packages/blade/codemods/brand-refresh/transformers/index.ts index fe71c8737c0..d28bee51c7d 100644 --- a/packages/blade/codemods/brand-refresh/transformers/index.ts +++ b/packages/blade/codemods/brand-refresh/transformers/index.ts @@ -1,21 +1,15 @@ -import type { Transform, JSXAttribute, JSXExpressionContainer } from 'jscodeshift'; -import colorTokensMapping from './colorTokensMapping'; - -const isExpression = (prop: unknown): prop is JSXExpressionContainer => { - return (prop as JSXAttribute)?.value?.type === 'JSXExpressionContainer'; -}; - -export const red = (message: string): string => `\u001b[1m\u001b[31m${message}\u001b[39m\u001b[22m`; +import type { Transform } from 'jscodeshift'; +import migrateAmountComponent from './migrate-amount'; +import migrateDividerComponent from './migrate-divider'; +import migrateCardComponent from './migrate-card'; +import migrateBadgeComponent from './migrate-badge'; +import migrateContrastIntentAndColorProps from './migrate-contrast-intent-color-props'; +import migrateTypographyComponents from './migrate-typography'; +import { red, isExpression } from './utils'; +// eslint-disable-next-line import/extensions +import colorTokensMapping from './colorTokensMapping.json'; const transformer: Transform = (file, api, options) => { - // Maps to transform Title sizes to Heading sizes - const titleToHeadingMap = { - xlarge: '2xlarge', - large: 'xlarge', - medium: 'xlarge', - small: 'large', - }; - // Maps for fontSize, lineHeight, and token prefixes const fontSizeMap = { 600: 500, @@ -134,7 +128,7 @@ const transformer: Transform = (file, api, options) => { const isBoxComponent = parent.value.name.name === 'Box'; const isIconComponent = parent.value.name.name?.includes('Icon'); - const isBorderColorProp = node.name.name.includes('border'); + const isBorderColorProp = (node.name.name as string).includes('border'); const isColorProp = node.name.name === 'color'; if (isBoxComponent && isBorderColorProp) { @@ -172,659 +166,33 @@ const transformer: Transform = (file, api, options) => { ); } - // Select Typography elements based on their names - const typographyJSXElements = root - .find(j.JSXElement) - .filter((path) => - ['Text', 'Title', 'Code', 'Display', 'Heading'].includes(path.value.openingElement.name.name), - ); - - // Update to - try { - typographyJSXElements - .filter( - (path) => - path.value.openingElement.name.name === 'Text' && - path.value.openingElement.attributes.some( - (attribute) => - attribute.name?.name === 'variant' && attribute.value?.value === 'caption', - ), - ) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'size' && path.node.value.value === 'medium') - .replaceWith((path) => { - path.node.value.value = 'small'; - return path.node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Text size prop.`), - `\n${red(error.stack)}\n`, - ); - } + migrateTypographyComponents({ root, j, file }); + migrateContrastIntentAndColorProps({ root, j, file }); + migrateBadgeComponent({ root, j, file }); + migrateCardComponent({ root, j, file }); + migrateAmountComponent({ root, j, file }); + migrateDividerComponent({ root, j, file }); - // Update to , - // , to , and - // to - try { - typographyJSXElements - .filter((path) => path.value.openingElement.name.name === 'Heading') - // replace with Heading - .replaceWith((path) => { - const { node } = path; - - const sizeAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'size', - ); - - const variantAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'variant', - ); - - if (isExpression(sizeAttribute) || isExpression(variantAttribute)) { - console.warn( - red( - '\n⛔️ Expression found in the "size"/"variant" attribute, please update manually:', - ), - red(`${file.path}:${sizeAttribute.loc.start.line}:${node.loc.start.column}\n`), - ); - return node; - } - - const otherAttributes = node.openingElement.attributes.filter( - (attribute) => attribute.name?.name !== 'variant' && attribute.name?.name !== 'size', - ); - - const headingSizeMap = { - large: 'medium', - medium: 'small', - small: 'large', - }; - - // If size is small or variant is subheading, replace with Text - if ( - !sizeAttribute || - (sizeAttribute && sizeAttribute.value.value === 'small') || - (variantAttribute && variantAttribute.value.value === 'subheading') - ) { - node.openingElement.name.name = 'Text'; - node.closingElement.name.name = 'Text'; - } - - if ( - !sizeAttribute && - (!variantAttribute || (variantAttribute && variantAttribute.value.value === 'regular')) - ) { - otherAttributes.push(j.jsxAttribute(j.jsxIdentifier('size'), j.literal('large'))); - } else if (sizeAttribute) { - otherAttributes.push( - j.jsxAttribute( - j.jsxIdentifier('size'), - j.literal(headingSizeMap[sizeAttribute.value.value]), - ), - ); - } else if (variantAttribute && variantAttribute.value.value === 'subheading') { - otherAttributes.push(j.jsxAttribute(j.jsxIdentifier('size'), j.literal('small'))); - } - - node.openingElement.attributes = otherAttributes; - - return node; - }) - .find(j.JSXAttribute) - .filter( - (path, index, self) => - path.node.name.name === 'variant' && - index === self.findIndex((obj) => path.node.start === obj.node.start), - ) // Filter by name `variant` and remove any duplicates - .remove(); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while updating the Heading size and variant props.`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Replace Title with Heading and update the 'size' attribute - // to <Heading size="xlarge"> - try { - typographyJSXElements - .filter((path) => path.value.openingElement.name.name === 'Title') - // replace with Heading - .replaceWith((path) => { - const { node } = path; - - node.openingElement.name.name = 'Heading'; - node.closingElement.name.name = 'Heading'; - - const sizeAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'size', - ); - - if (isExpression(sizeAttribute)) { - console.warn( - red('\n⛔️ Expression found in the "size" attribute, please update manually:'), - red(`${file.path}:${sizeAttribute.loc.start.line}:${node.loc.start.column}\n`), - ); - return node; - } - - if (!sizeAttribute) { - node.openingElement.attributes.push( - j.jsxAttribute(j.jsxIdentifier('size'), j.literal('large')), - ); - - return node; - } - - const otherAttributes = node.openingElement.attributes.filter( - (attribute) => attribute.name?.name !== 'size', - ); - otherAttributes.push( - j.jsxAttribute( - j.jsxIdentifier('size'), - j.literal(titleToHeadingMap[sizeAttribute.value.value] || 'large'), - ), - ); - - node.openingElement.attributes = otherAttributes; - - return node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while migrating the Title component`), - `\n${red(error.stack)}\n`, - ); - } - - // Remove/Update the Title import from "@razorpay/blade/components" + // Update ImportDeclaration from "@razorpay/blade/components" to "@razorpay/blade-rebranded/components" + // Update ImportSpecifier from "paymentTheme"/"bankingTheme" to "bladeTheme" try { root .find(j.ImportDeclaration) - .filter( - (path) => - path.value.source.value === '@razorpay/blade/components' || - path.value.source.value === '@razorpay/blade/tokens', - ) - .find(j.ImportSpecifier) .filter((path) => - ['Title', 'paymentTheme', 'bankingTheme'].includes(path.value.imported.name), + /@razorpay\/blade\/(components|utils|tokens)/i.test(path.value.source.value as string), ) .replaceWith((path) => { - // Check if Heading import is already present - const isHeadingImportPresent = path.parent.value.specifiers.some( - (node) => node.imported.name === 'Heading', + path.value.source.value = (path.value.source.value as string).replace( + 'blade', + 'blade-rebranded', ); - const isThemeImportPresent = - path.value.imported.name === 'paymentTheme' || - path.value.imported.name === 'bankingTheme'; - - if (isThemeImportPresent) { - path.value.imported.name = 'bladeTheme'; - } - // If Heading import is not present, update the "Title" import to use "Heading" - else if (!isHeadingImportPresent) { - path.value.imported.name = 'Heading'; - } else { - // If "Heading" import is present, remove the "Title" import - path.parent.value.specifiers = path.parent.value.specifiers.filter( - (node) => node.imported.name !== 'Title', - ); - } return path.node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Title import.`), - `\n${red(error.stack)}\n`, - ); - } - - // Remove `type` and contrast="low" prop from Typography & ProgressBar Components - try { - root - .find(j.JSXElement) - .filter((path) => - /(Text|Title|Display|Heading|ProgressBar)/i.test(path.value.openingElement.name.name), - ) - .replaceWith((path) => { - const { node } = path; - - // If the node is a ProgressBar, return the node - if (node.openingElement.name.name === 'ProgressBar') { - return node; - } - - const colorAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'color', - ); - - if (colorAttribute) { - node.openingElement.attributes = node.openingElement.attributes.filter( - (attribute) => attribute.name?.name !== 'contrast' && attribute.name?.name !== 'type', - ); - - return node; - } - - const typeAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'type', - ); - - const contrastAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'contrast', - ); - - // If type and contrast are not present, return the node - if (!(typeAttribute || contrastAttribute)) { - return node; - } - - const typeValue = typeAttribute?.value.value || 'normal'; - const contrastValue = contrastAttribute?.value.value || 'low'; - - const oldColorToken = `surface.text.${typeValue}.${contrastValue}Contrast`; - const newColorToken = colorTokensMapping[oldColorToken]; - - if (newColorToken) { - node.openingElement.attributes?.push( - j.jsxAttribute(j.jsxIdentifier('color'), j.literal(newColorToken)), - ); - } - - return node; }) - .find(j.JSXAttribute) // Find all Heading props - .filter( - (path, index, self) => - (path.node.name.name === 'type' || - (path.node.name.name === 'contrast' && path.node.value.value === 'low')) && - index === self.findIndex((obj) => path.node.start === obj.node.start), - ) // Filter by name `type` and remove any duplicates - .remove(); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while removing the "type" prop from Typography Components:`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Break `contrast="high"` prop from Typography & ProgressBar Components - try { - root - .find(j.JSXElement) - .filter((path) => - /(Text|Title|Display|Heading|ProgressBar)/i.test(path.value.openingElement.name.name), - ) - .find(j.JSXAttribute) // Find all Heading props - .filter( - (path, index, self) => - // Only Typography components - /(Text|Title|Display|Heading|ProgressBar)/i.test(path.parent.value.name.name) && - path.node.name.name === 'contrast' && - path.node.value.value === 'high' && - index === self.findIndex((obj) => path.node.start === obj.node.start), - ) - .replaceWith((path) => { - path.node.value.value = 'UPDATE_THIS_VALUE_WITH_A_NEW_COLOR_TOKEN'; - return path.node; - }); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while breaking the "contrast" prop from Typography Components:`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Change 'weight="bold"' to 'weight="semibold"' in Heading, Text, Display - // Code still uses 'weight="bold"' and Title has been modified to the Heading Component - try { - typographyJSXElements - .filter((path) => - ['Heading', 'Text', 'Display'].includes(path.value.openingElement.name.name), - ) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'weight' && path.node.value.value === 'bold') - .replaceWith((path) => { - path.node.value.value = 'semibold'; - return path.node; - }); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while updating the "weight" prop in Typography Components:`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Bade/Counter/IconButton - try { - root - .find(j.JSXElement) - .filter((path) => - ['Badge', 'Counter', 'IconButton'].includes(path.value.openingElement.name.name), - ) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'contrast') - .replaceWith((path) => { - path.node.name.name = 'emphasis'; - - const contrastToEmphasisMap = { - badge: { - low: 'subtle', - high: 'intense', - }, - counter: { - low: 'subtle', - high: 'intense', - }, - iconbutton: { - low: 'intense', - high: 'subtle', - }, - }; - - path.node.value.value = - contrastToEmphasisMap[path.parent.value.name.name.toLowerCase()][[path.node.value.value]]; - - return path.node; - }); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while updating the "contrast" prop in Bade/Counter/IconButton Components:`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Remove deprecated 'intent'/'variant' props in favor of color - try { - root - .find(j.JSXElement) - .filter((path) => - [ - 'Alert', - 'Badge', - 'Counter', - 'Chip', - 'ChipGroup', - 'Indicator', - 'ProgressBar', - 'Amount', - ].includes(path.value.openingElement.name.name), - ) - .replaceWith((path) => { - const { node } = path; - - const colorAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'color', - ); - - if (colorAttribute) { - node.openingElement.attributes = node.openingElement.attributes.filter( - (attribute) => attribute.name?.name !== 'intent' && attribute.name?.name !== 'variant', - ); - - return node; - } - - const variantAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'variant', - ); - - const intentAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'intent', - ); - - // If type and contrast are not present, return the node - if (!(variantAttribute || intentAttribute)) { - return node; - } - - const variantValue = variantAttribute?.value.value; - const intentValue = intentAttribute?.value.value; - - node.openingElement.attributes?.push( - j.jsxAttribute( - j.jsxIdentifier('color'), - j.literal( - ['blue', 'none'].includes(variantValue || intentValue) - ? 'primary' - : variantValue || intentValue, - ), - ), - ); - - return node; - }) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'intent' || path.node.name.name === 'variant') - .filter( - (path, index, self) => - (path.node.name.name === 'intent' || path.node.name.name === 'variant') && - index === self.findIndex((obj) => path.node.start === obj.node.start), - ) - .replaceWith((path) => { - if (path.node.value.value === 'blue' || path.node.value.value === 'default') { - path.node.value.value = 'primary'; - } - - return path.node; - }) - .remove(); - } catch (error) { - console.error( - red( - `⛔️ ${file.path}: Oops! Ran into an issue while removing the deprecated "intent" and "variant" props.`, - ), - `\n${red(error.stack)}\n`, - ); - } - - // Change color="default" to color="primary" in Button/Link/Badge/Counter - // <Button variant="secondary" color="default"> -> <Button variant="secondary" color="primary"> - try { - root - .find(j.JSXElement) - .filter((path) => - ['Button', 'Link', 'Badge', 'Counter'].includes(path.value.openingElement.name.name), - ) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'color' && path.node.value.value === 'default') - .replaceWith((path) => { - path.node.value.value = 'primary'; - - return path.node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Button color prop.`), - `\n${red(error.stack)}\n`, - ); - } - - // Remove forntWeight prop from Badge - try { - root - .find(j.JSXElement) - .filter((path) => path.value.openingElement.name.name === 'Badge') - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'fontWeight') - .remove(); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while removing the fontWeight prop.`), - `\n${red(error.stack)}\n`, - ); - } - - // Card component <Card surfaceLevel={2|3} > -> <Card backgroundColor=”surface.background.gray.intense|surface.background.gray.moderate”> - try { - root - .find(j.JSXElement) - .filter((path) => ['Card'].includes(path.value.openingElement.name.name)) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'surfaceLevel') - .replaceWith((path) => { - const { node } = path; - - const surfaceLevelMap = { - 2: 'surface.background.gray.intense', - 3: 'surface.background.gray.moderate', - }; - - node.name.name = 'backgroundColor'; - - node.value = j.literal(surfaceLevelMap[node.value.expression.value]); - - delete node.value.expression; - - return node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Card component.`), - `\n${red(error.stack)}\n`, - ); - } - - // Divider component <Divider variant=”normal”> -> <Divider variant=”muted”> - try { - root - .find(j.JSXElement) - .filter((path) => ['Divider'].includes(path.value.openingElement.name.name)) - .find(j.JSXAttribute) - .filter((path) => path.node.name.name === 'variant' && path.node.value.value === 'normal') - .replaceWith((path) => { - const { node } = path; - - node.value.value = 'muted'; - - return node; - }); - } catch (error) { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Divider component.`), - `\n${red(error.stack)}\n`, - ); - } - - // Amount component <Amount size=”heading-small-bold”> -> <Amount type=”heading” size=”small” weight=”semibold”> - try { - root - .find(j.JSXElement) - .filter((path) => ['Amount'].includes(path.value.openingElement.name.name)) - .replaceWith((path) => { - const { node } = path; - - const sizeAttribute = node.openingElement.attributes.find( - (attribute) => attribute.name?.name === 'size', - ); - - if (!sizeAttribute) { - return node; - } - - if (isExpression(sizeAttribute)) { - console.warn( - red('\n⛔️ Expression found in the "size" attribute, please update manually:'), - red(`${file.path}:${sizeAttribute.loc.start.line}:${node.loc.start.column}\n`), - ); - return node; - } - - const otherAttributes = node.openingElement.attributes.filter( - (attribute) => attribute.name?.name !== 'size', - ); - - const sizeMap = { - 'body-small': { - type: 'body', - size: 'small', - }, - 'body-small-bold': { - type: 'body', - size: 'small', - weight: 'semibold', - }, - 'body-medium': { - type: 'body', - size: 'medium', - }, - 'body-medium-bold': { - type: 'body', - size: 'medium', - weight: 'semibold', - }, - 'heading-small': { - type: 'body', - size: 'large', - }, - 'heading-small-bold': { - type: 'body', - size: 'large', - weight: 'semibold', - }, - 'heading-large': { - type: 'heading', - size: 'medium', - }, - 'heading-large-bold': { - type: 'heading', - size: 'medium', - weight: 'semibold', - }, - 'title-small': { - type: 'heading', - size: 'large', - }, - 'title-medium': { - type: 'heading', - size: 'xlarge', - }, - }; - - const sizeAttributeValue = sizeAttribute.value.value; - - Object.keys(sizeMap[sizeAttribute.value.value]).forEach((key) => { - otherAttributes.push( - j.jsxAttribute( - j.jsxIdentifier(key), - j.literal(sizeMap[sizeAttributeValue][key as keyof typeof sizeMap]), - ), - ); - }); - - node.openingElement.attributes = otherAttributes; - - return node; - }); - } catch { - console.error( - red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Amount component.`), - `\n${red(error.stack)}\n`, - ); - } - - // Update ImportDeclaration from "@razorpay/blade/components" to "@razorpay/blade-rebranded/components" - try { - root - .find(j.ImportDeclaration) - .filter((path) => - /@razorpay\/blade\/(components|utils|tokens)/i.test(path.value.source.value), - ) + .find(j.ImportSpecifier) + .filter((path) => ['paymentTheme', 'bankingTheme'].includes(path.value.imported.name)) .replaceWith((path) => { - path.value.source.value = path.value.source.value.replace('blade', 'blade-rebranded'); + path.value.imported.name = 'bladeTheme'; return path.node; }); diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-amount.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-amount.ts new file mode 100644 index 00000000000..bceba6070cd --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-amount.ts @@ -0,0 +1,102 @@ +import { red, isExpression } from './utils'; + +// Amount component <Amount size=”heading-small-bold”> -> <Amount type=”heading” size=”small” weight=”semibold”> +function migrateAmountComponent({ root, j, file }): void { + try { + root + .find(j.JSXElement) + .filter((path) => ['Amount'].includes(path.value.openingElement.name.name)) + .replaceWith((path) => { + const { node } = path; + + const sizeAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'size', + ); + + if (!sizeAttribute) { + return node; + } + + if (isExpression(sizeAttribute)) { + console.warn( + red('\n⛔️ Expression found in the "size" attribute, please update manually:'), + red(`${file.path}:${sizeAttribute?.loc?.start.line}:${node.loc.start.column}\n`), + ); + return node; + } + + const otherAttributes = node.openingElement.attributes.filter( + (attribute) => attribute.name?.name !== 'size', + ); + + const sizeMap = { + 'body-small': { + type: 'body', + size: 'small', + }, + 'body-small-bold': { + type: 'body', + size: 'small', + weight: 'semibold', + }, + 'body-medium': { + type: 'body', + size: 'medium', + }, + 'body-medium-bold': { + type: 'body', + size: 'medium', + weight: 'semibold', + }, + 'heading-small': { + type: 'body', + size: 'large', + }, + 'heading-small-bold': { + type: 'body', + size: 'large', + weight: 'semibold', + }, + 'heading-large': { + type: 'heading', + size: 'medium', + }, + 'heading-large-bold': { + type: 'heading', + size: 'medium', + weight: 'semibold', + }, + 'title-small': { + type: 'heading', + size: 'large', + }, + 'title-medium': { + type: 'heading', + size: 'xlarge', + }, + }; + + const sizeAttributeValue = sizeAttribute.value.value; + + Object.keys(sizeMap[sizeAttribute.value.value]).forEach((key) => { + otherAttributes.push( + j.jsxAttribute( + j.jsxIdentifier(key), + j.literal(sizeMap[sizeAttributeValue][key as keyof typeof sizeMap]), + ), + ); + }); + + node.openingElement.attributes = otherAttributes; + + return node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Amount component.`), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateAmountComponent; diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-badge.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-badge.ts new file mode 100644 index 00000000000..33247b72fa5 --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-badge.ts @@ -0,0 +1,20 @@ +import { red } from './utils'; + +// Badge component Migration, remove the `fortWeight` prop +function migrateBadgeComponent({ root, j, file }): void { + try { + root + .find(j.JSXElement) + .filter((path) => path.value.openingElement.name.name === 'Badge') + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'fontWeight') + .remove(); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while removing the fontWeight prop.`), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateBadgeComponent; diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-card.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-card.ts new file mode 100644 index 00000000000..cdb6c29021b --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-card.ts @@ -0,0 +1,36 @@ +import { red } from './utils'; + +// Card component Migration +// <Card surfaceLevel={2|3} > -> <Card backgroundColor=”surface.background.gray.intense|surface.background.gray.moderate”> +function migrateCardComponent({ root, j, file }): void { + try { + root + .find(j.JSXElement) + .filter((path) => ['Card'].includes(path.value.openingElement.name.name)) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'surfaceLevel') + .replaceWith((path) => { + const { node } = path; + + const surfaceLevelMap = { + 2: 'surface.background.gray.intense', + 3: 'surface.background.gray.moderate', + }; + + node.name.name = 'backgroundColor'; + + node.value = j.literal(surfaceLevelMap[node.value.expression.value]); + + delete node.value.expression; + + return node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Card component.`), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateCardComponent; diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-contrast-intent-color-props.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-contrast-intent-color-props.ts new file mode 100644 index 00000000000..7d9882f34f2 --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-contrast-intent-color-props.ts @@ -0,0 +1,184 @@ +import { red } from './utils'; + +function migrateContrastIntentAndColorProps({ root, j, file }): void { + // Break `contrast="high"` prop from Typography & ProgressBar Components + try { + root + .find(j.JSXElement) + .filter((path) => + ['Text', 'Title', 'Display', 'Heading', 'ProgressBar'].includes( + path.value.openingElement.name.name, + ), + ) + .find(j.JSXAttribute) // Find all props + .filter( + (path, index, self) => + ['Text', 'Title', 'Display', 'Heading', 'ProgressBar'].includes( + path.parent.value.name.name, + ) && + path.node.name.name === 'contrast' && + path.node.value.value === 'high' && + index === self.findIndex((obj) => path.node.start === obj.node.start), + ) + .replaceWith((path) => { + path.node.value.value = 'UPDATE_THIS_VALUE_WITH_A_NEW_COLOR_TOKEN'; + return path.node; + }); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while breaking the "contrast" prop from Typography Components:`, + ), + `\n${red(error.stack)}\n`, + ); + } + + // Bade/Counter/IconButton Components: Change `contrast` prop to `emphasis` + try { + root + .find(j.JSXElement) + .filter((path) => + ['Badge', 'Counter', 'IconButton'].includes(path.value.openingElement.name.name), + ) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'contrast') + .replaceWith((path) => { + path.node.name.name = 'emphasis'; + + const contrastToEmphasisMap = { + badge: { + low: 'subtle', + high: 'intense', + }, + counter: { + low: 'subtle', + high: 'intense', + }, + iconbutton: { + low: 'intense', + high: 'subtle', + }, + }; + + path.node.value.value = + contrastToEmphasisMap[path.parent.value.name.name.toLowerCase()][[path.node.value.value]]; + + return path.node; + }); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while updating the "contrast" prop in Bade/Counter/IconButton Components:`, + ), + `\n${red(error.stack)}\n`, + ); + } + + // Remove deprecated 'intent'/'variant' props in favor of color + try { + root + .find(j.JSXElement) + .filter((path) => + [ + 'Alert', + 'Badge', + 'Counter', + 'Chip', + 'ChipGroup', + 'Indicator', + 'ProgressBar', + 'Amount', + ].includes(path.value.openingElement.name.name), + ) + .replaceWith((path) => { + const { node } = path; + + const colorAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'color', + ); + + if (colorAttribute) { + node.openingElement.attributes = node.openingElement.attributes.filter( + (attribute) => attribute.name?.name !== 'intent' && attribute.name?.name !== 'variant', + ); + + return node; + } + + const variantAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'variant', + ); + + const intentAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'intent', + ); + + // If type and contrast are not present, return the node + if (!(variantAttribute || intentAttribute)) { + return node; + } + + const variantValue = variantAttribute?.value.value; + const intentValue = intentAttribute?.value.value; + + node.openingElement.attributes?.push( + j.jsxAttribute( + j.jsxIdentifier('color'), + j.literal( + ['blue', 'none'].includes(variantValue || intentValue) + ? 'primary' + : variantValue || intentValue, + ), + ), + ); + + return node; + }) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'intent' || path.node.name.name === 'variant') + .filter( + (path, index, self) => + (path.node.name.name === 'intent' || path.node.name.name === 'variant') && + index === self.findIndex((obj) => path.node.start === obj.node.start), + ) + .replaceWith((path) => { + if (path.node.value.value === 'blue' || path.node.value.value === 'default') { + path.node.value.value = 'primary'; + } + + return path.node; + }) + .remove(); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while removing the deprecated "intent" and "variant" props.`, + ), + `\n${red(error.stack)}\n`, + ); + } + + // Change color="default" to color="primary" in Button/Link/Badge/Counter + // <Button variant="secondary" color="default"> -> <Button variant="secondary" color="primary"> + try { + root + .find(j.JSXElement) + .filter((path) => + ['Button', 'Link', 'Badge', 'Counter'].includes(path.value.openingElement.name.name), + ) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'color' && path.node.value.value === 'default') + .replaceWith((path) => { + path.node.value.value = 'primary'; + + return path.node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Button color prop.`), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateContrastIntentAndColorProps; diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-divider.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-divider.ts new file mode 100644 index 00000000000..77e21e23af8 --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-divider.ts @@ -0,0 +1,26 @@ +import { red } from './utils'; + +// Divider component <Divider variant=”normal”> -> <Divider variant=”muted”> +function migrateDividerComponent({ root, j, file }): void { + try { + root + .find(j.JSXElement) + .filter((path) => ['Divider'].includes(path.value.openingElement.name.name)) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'variant' && path.node.value.value === 'normal') + .replaceWith((path) => { + const { node } = path; + + node.value.value = 'muted'; + + return node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Divider component.`), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateDividerComponent; diff --git a/packages/blade/codemods/brand-refresh/transformers/migrate-typography.ts b/packages/blade/codemods/brand-refresh/transformers/migrate-typography.ts new file mode 100644 index 00000000000..a10f5c5166c --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/migrate-typography.ts @@ -0,0 +1,308 @@ +import { red, isExpression } from './utils'; +// eslint-disable-next-line import/extensions +import colorTokensMapping from './colorTokensMapping.json'; + +function migrateTypographyComponents({ root, j, file }): void { + // Select Typography elements based on their names + const typographyJSXElements = root + .find(j.JSXElement) + .filter((path) => + ['Text', 'Title', 'Code', 'Display', 'Heading'].includes(path.value.openingElement.name.name), + ); + + // Update <Text variant="caption" size="medium" > to <Text variant="caption" size="small" > + try { + typographyJSXElements + .filter( + (path) => + path.value.openingElement.name.name === 'Text' && + path.value.openingElement.attributes.some( + (attribute) => + attribute.name?.name === 'variant' && attribute.value?.value === 'caption', + ), + ) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'size' && path.node.value.value === 'medium') + .replaceWith((path) => { + path.node.value.value = 'small'; + return path.node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Text size prop.`), + `\n${red(error.stack)}\n`, + ); + } + + // Update <Heading size="large|medium"> to <Heading size="medium|small">, + // <Heading size="small">, <Heading variant="regular"> to <Text size="large">, and + // <Heading variant="subheading"> to <Text size="small"> + try { + typographyJSXElements + .filter((path) => path.value.openingElement.name.name === 'Heading') + // replace with Heading + .replaceWith((path) => { + const { node } = path; + + const sizeAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'size', + ); + + const variantAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'variant', + ); + + if (isExpression(sizeAttribute) || isExpression(variantAttribute)) { + console.warn( + red( + '\n⛔️ Expression found in the "size"/"variant" attribute, please update manually:', + ), + red(`${file.path}:${sizeAttribute.loc.start.line}:${node.loc.start.column}\n`), + ); + return node; + } + + const otherAttributes = node.openingElement.attributes.filter( + (attribute) => attribute.name?.name !== 'variant' && attribute.name?.name !== 'size', + ); + + const headingSizeMap = { + large: 'medium', + medium: 'small', + small: 'large', + }; + + // If size is small or variant is subheading, replace with Text + if ( + !sizeAttribute || + (sizeAttribute && sizeAttribute.value.value === 'small') || + (variantAttribute && variantAttribute.value.value === 'subheading') + ) { + node.openingElement.name.name = 'Text'; + node.closingElement.name.name = 'Text'; + } + + if ( + !sizeAttribute && + (!variantAttribute || (variantAttribute && variantAttribute.value.value === 'regular')) + ) { + otherAttributes.push(j.jsxAttribute(j.jsxIdentifier('size'), j.literal('large'))); + } else if (sizeAttribute) { + otherAttributes.push( + j.jsxAttribute( + j.jsxIdentifier('size'), + j.literal(headingSizeMap[sizeAttribute.value.value]), + ), + ); + } else if (variantAttribute && variantAttribute.value.value === 'subheading') { + otherAttributes.push(j.jsxAttribute(j.jsxIdentifier('size'), j.literal('small'))); + } + + node.openingElement.attributes = otherAttributes; + + return node; + }) + .find(j.JSXAttribute) + .filter( + (path, index, self) => + path.node.name.name === 'variant' && + index === self.findIndex((obj) => path.node.start === obj.node.start), + ) // Filter by name `variant` and remove any duplicates + .remove(); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while updating the Heading size and variant props.`, + ), + `\n${red(error.stack)}\n`, + ); + } + + // Replace Title with Heading and update the 'size' attribute + // <Title size="medium"> to <Heading size="xlarge"> + try { + typographyJSXElements + .filter((path) => path.value.openingElement.name.name === 'Title') + // replace with Heading + .replaceWith((path) => { + const { node } = path; + + node.openingElement.name.name = 'Heading'; + node.closingElement.name.name = 'Heading'; + + const sizeAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'size', + ); + + if (isExpression(sizeAttribute)) { + console.warn( + red('\n⛔️ Expression found in the "size" attribute, please update manually:'), + red(`${file.path}:${sizeAttribute.loc?.start.line}:${node.loc.start.column}\n`), + ); + return node; + } + + if (!sizeAttribute) { + node.openingElement.attributes.push( + j.jsxAttribute(j.jsxIdentifier('size'), j.literal('large')), + ); + + return node; + } + + // Maps to transform Title sizes to Heading sizes + const titleToHeadingMap = { + xlarge: '2xlarge', + large: 'xlarge', + medium: 'xlarge', + small: 'large', + }; + + const otherAttributes = node.openingElement.attributes.filter( + (attribute) => attribute.name?.name !== 'size', + ); + otherAttributes.push( + j.jsxAttribute( + j.jsxIdentifier('size'), + j.literal(titleToHeadingMap[sizeAttribute.value.value] || 'large'), + ), + ); + + node.openingElement.attributes = otherAttributes; + + return node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while migrating the Title component`), + `\n${red(error.stack)}\n`, + ); + } + + // Remove/Update the Title import from "@razorpay/blade/components" + try { + root + .find(j.ImportDeclaration) + .filter((path) => path.value.source.value === '@razorpay/blade/components') + .find(j.ImportSpecifier) + .filter((path) => ['Title'].includes(path.value.imported.name)) + .replaceWith((path) => { + // Check if Heading import is already present + const isHeadingImportPresent = path.parent.value.specifiers.some( + (node) => node.imported.name === 'Heading', + ); + + // If Heading import is not present, update the "Title" import to use "Heading" + if (!isHeadingImportPresent) { + path.value.imported.name = 'Heading'; + } else { + // If "Heading" import is present, remove the "Title" import + path.parent.value.specifiers = path.parent.value.specifiers.filter( + (node) => node.imported.name !== 'Title', + ); + } + + return path.node; + }); + } catch (error) { + console.error( + red(`⛔️ ${file.path}: Oops! Ran into an issue while updating the Title import.`), + `\n${red(error.stack)}\n`, + ); + } + + // Remove `type` and contrast="low" prop from Typography & ProgressBar Components + try { + root + .find(j.JSXElement) + .filter((path) => + /(Text|Title|Display|Heading|ProgressBar)/i.test(path.value.openingElement.name.name), + ) + .replaceWith((path) => { + const { node } = path; + + // If the node is a ProgressBar, return the node + if (node.openingElement.name.name === 'ProgressBar') { + return node; + } + + const colorAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'color', + ); + + if (colorAttribute) { + node.openingElement.attributes = node.openingElement.attributes.filter( + (attribute) => attribute.name?.name !== 'contrast' && attribute.name?.name !== 'type', + ); + + return node; + } + + const typeAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'type', + ); + + const contrastAttribute = node.openingElement.attributes.find( + (attribute) => attribute.name?.name === 'contrast', + ); + + // If type and contrast are not present, return the node + if (!(typeAttribute || contrastAttribute)) { + return node; + } + + const typeValue = typeAttribute?.value.value || 'normal'; + const contrastValue = contrastAttribute?.value.value || 'low'; + + const oldColorToken = `surface.text.${typeValue}.${contrastValue}Contrast`; + const newColorToken = colorTokensMapping[oldColorToken]; + + if (newColorToken) { + node.openingElement.attributes?.push( + j.jsxAttribute(j.jsxIdentifier('color'), j.literal(newColorToken)), + ); + } + + return node; + }) + .find(j.JSXAttribute) // Find all Heading props + .filter( + (path, index, self) => + (path.node.name.name === 'type' || + (path.node.name.name === 'contrast' && path.node.value.value === 'low')) && + index === self.findIndex((obj) => path.node.start === obj.node.start), + ) // Filter by name `type` and remove any duplicates + .remove(); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while removing the "type" prop from Typography Components:`, + ), + `\n${red(error.stack)}\n`, + ); + } + + // Change 'weight="bold"' to 'weight="semibold"' in Heading, Text, Display + // Code still uses 'weight="bold"' and Title has been modified to the Heading Component + try { + typographyJSXElements + .filter((path) => + ['Heading', 'Text', 'Display'].includes(path.value.openingElement.name.name), + ) + .find(j.JSXAttribute) + .filter((path) => path.node.name.name === 'weight' && path.node.value.value === 'bold') + .replaceWith((path) => { + path.node.value.value = 'semibold'; + return path.node; + }); + } catch (error) { + console.error( + red( + `⛔️ ${file.path}: Oops! Ran into an issue while updating the "weight" prop in Typography Components:`, + ), + `\n${red(error.stack)}\n`, + ); + } +} + +export default migrateTypographyComponents; diff --git a/packages/blade/codemods/brand-refresh/transformers/utils.ts b/packages/blade/codemods/brand-refresh/transformers/utils.ts new file mode 100644 index 00000000000..1fc6b3a7efb --- /dev/null +++ b/packages/blade/codemods/brand-refresh/transformers/utils.ts @@ -0,0 +1,7 @@ +import type { JSXAttribute, JSXExpressionContainer } from 'jscodeshift'; + +export const isExpression = (prop: unknown): prop is JSXExpressionContainer => { + return (prop as JSXAttribute)?.value?.type === 'JSXExpressionContainer'; +}; + +export const red = (message: string): string => `\u001b[1m\u001b[31m${message}\u001b[39m\u001b[22m`;