Join GitHub today
GitHub is home to over 50 million developers working together to host and review code, manage projects, and build software together.
Sign upGitHub is where the world builds software
Millions of developers and companies build, ship, and maintain their software on GitHub — the largest and most advanced development platform in the world.
| /* eslint-disable no-restricted-syntax, no-use-before-define, @typescript-eslint/no-use-before-define */ | |
| import ts from 'typescript'; | |
| import { Plugin } from 'ts-migrate-server'; | |
| import { | |
| isReactClassComponent, | |
| getReactComponentHeritageType, | |
| isReactSfcFunctionDeclaration, | |
| isReactSfcArrowFunction, | |
| } from './utils/react'; | |
| import isNotNull from '../utils/isNotNull'; | |
| import updateSourceText, { SourceTextUpdate } from '../utils/updateSourceText'; | |
| import getTypeFromPropTypesObjectLiteral, { createPropsTypeNameGetter } from './utils/react-props'; | |
| import { getTextPreservingWhitespace } from './utils/text'; | |
| import { updateImports, DefaultImport, NamedImport } from './utils/imports'; | |
| type Options = { | |
| anyAlias?: string; | |
| anyFunctionAlias?: string; | |
| shouldUpdateAirbnbImports?: boolean; | |
| }; | |
| const reactPropsPlugin: Plugin<Options> = { | |
| name: 'react-props', | |
| run({ fileName, sourceFile, options }) { | |
| if (!fileName.endsWith('.tsx')) return undefined; | |
| const updates: SourceTextUpdate[] = []; | |
| const getPropsTypeName = createPropsTypeNameGetter(sourceFile); | |
| for (const node of sourceFile.statements) { | |
| if (isReactNode(node)) { | |
| const componentName = getComponentName(node); | |
| const propsTypeName = getPropsTypeName(componentName); | |
| updates.push(...updatePropTypes(node, propsTypeName, sourceFile, options)); | |
| } | |
| } | |
| const updatedSourceText = updateSourceText(sourceFile.text, updates); | |
| const updatedSourceFile = ts.createSourceFile( | |
| fileName, | |
| updatedSourceText, | |
| sourceFile.languageVersion, | |
| ); | |
| const importUpdates = updateImports( | |
| updatedSourceFile, | |
| spreadReplacements.map((cur) => cur.typeImport), | |
| [ | |
| { moduleSpecifier: 'prop-types' }, | |
| ...(options.shouldUpdateAirbnbImports ? importReplacements : []), | |
| ...(options.shouldUpdateAirbnbImports | |
| ? spreadReplacements.map((cur) => cur.spreadImport) | |
| : []), | |
| ], | |
| ); | |
| return updateSourceText(updatedSourceText, importUpdates); | |
| }, | |
| }; | |
| export default reactPropsPlugin; | |
| type SpreadReplacement = { | |
| spreadId: string; | |
| spreadImport: DefaultImport | NamedImport; | |
| typeRef: ts.TypeReferenceNode; | |
| typeImport: DefaultImport | NamedImport; | |
| }; | |
| // airbnb related imports | |
| const importReplacements = [{ moduleSpecifier: 'airbnb-prop-types' }]; | |
| const spreadReplacements: SpreadReplacement[] = [ | |
| { | |
| spreadId: 'withStylesPropTypes', | |
| spreadImport: { | |
| namedImport: 'withStylesPropTypes', | |
| moduleSpecifier: ':dls-themes/withStyles', | |
| }, | |
| typeRef: ts.createTypeReferenceNode('WithStylesProps', undefined), | |
| typeImport: { | |
| namedImport: 'WithStylesProps', | |
| moduleSpecifier: ':dls-themes/withStyles', | |
| }, | |
| }, | |
| { | |
| spreadId: 'withBreakpointPropTypes', | |
| spreadImport: { | |
| namedImport: 'withBreakpointPropTypes', | |
| moduleSpecifier: ':dls-core/components/breakpoints/withBreakpoint', | |
| }, | |
| typeRef: ts.createTypeReferenceNode('WithBreakpointProps', undefined), | |
| typeImport: { | |
| namedImport: 'WithBreakpointProps', | |
| moduleSpecifier: ':dls-core/components/breakpoints/withBreakpoint', | |
| }, | |
| }, | |
| { | |
| spreadId: 'withRouterPropTypes', | |
| spreadImport: { | |
| defaultImport: 'withRouterPropTypes', | |
| moduleSpecifier: ':routing/shapes/RR4PropTypes', | |
| }, | |
| typeRef: ts.createTypeReferenceNode('RouteConfigComponentProps', [ | |
| ts.createTypeLiteralNode([]), | |
| ]), | |
| typeImport: { | |
| namedImport: 'RouteConfigComponentProps', | |
| moduleSpecifier: 'react-router-config', | |
| }, | |
| }, | |
| ]; | |
| type ReactNode = ts.ClassDeclaration | ts.FunctionDeclaration | ts.VariableStatement; | |
| type ReactSfcNode = ts.FunctionDeclaration | ts.VariableStatement; | |
| function isReactNode(node: ts.Node): node is ReactNode { | |
| return ( | |
| (ts.isClassDeclaration(node) && isReactClassComponent(node)) || | |
| (ts.isFunctionDeclaration(node) && isReactSfcFunctionDeclaration(node)) || | |
| (ts.isVariableStatement(node) && isReactSfcArrowFunction(node)) | |
| ); | |
| } | |
| function isReactSfcNode(node: ReactNode): node is ReactSfcNode { | |
| return ts.isFunctionDeclaration(node) || ts.isVariableStatement(node); | |
| } | |
| function updatePropTypes( | |
| node: ReactNode, | |
| propsTypeName: string, | |
| sourceFile: ts.SourceFile, | |
| options: Options, | |
| ) { | |
| const updates: SourceTextUpdate[] = []; | |
| const printer = ts.createPrinter(); | |
| if (isReactSfcNode(node)) { | |
| const propsParam = getPropsParam(node); | |
| if (propsParam && !propsParam.type) { | |
| const propTypesNode = findSfcPropTypesNode(node, sourceFile); | |
| const objectLiteral = propTypesNode && findPropTypesObjectLiteral(propTypesNode, sourceFile); | |
| if (objectLiteral) { | |
| updates.push( | |
| ...updateObjectLiteral(node, objectLiteral, propsTypeName, sourceFile, options, false), | |
| ); | |
| updates.push({ | |
| kind: 'replace', | |
| index: propsParam.pos, | |
| length: propsParam.end - propsParam.pos, | |
| text: printer.printNode( | |
| ts.EmitHint.Unspecified, | |
| ts.updateParameter( | |
| propsParam, | |
| propsParam.decorators, | |
| propsParam.modifiers, | |
| propsParam.dotDotDotToken, | |
| propsParam.name, | |
| propsParam.questionToken, | |
| ts.createTypeReferenceNode(propsTypeName, undefined), | |
| propsParam.initializer, | |
| ), | |
| sourceFile, | |
| ), | |
| }); | |
| updates.push(...deleteSfcPropTypes(node, sourceFile)); | |
| } | |
| } | |
| } else { | |
| const heritageType = getReactComponentHeritageType(node)!; | |
| const heritageTypeArgs = heritageType.typeArguments || []; | |
| const propsType = heritageTypeArgs[0]; | |
| const stateType = heritageTypeArgs[1]; | |
| if (!propsType || isEmptyTypeLiteral(propsType)) { | |
| const propTypesNode = findClassPropTypesNode(node, sourceFile); | |
| const objectLiteral = propTypesNode && findPropTypesObjectLiteral(propTypesNode, sourceFile); | |
| if (objectLiteral) { | |
| updates.push( | |
| ...updateObjectLiteral(node, objectLiteral, propsTypeName, sourceFile, options, true), | |
| ); | |
| updates.push({ | |
| kind: 'replace', | |
| index: heritageType.pos, | |
| length: heritageType.end - heritageType.pos, | |
| text: ` ${printer.printNode( | |
| ts.EmitHint.Unspecified, | |
| ts.updateExpressionWithTypeArguments( | |
| heritageType, | |
| [ts.createTypeReferenceNode(propsTypeName, undefined), stateType].filter( | |
| isNotNull, | |
| ) as any, | |
| heritageType.expression, | |
| ), | |
| sourceFile, | |
| )}`, | |
| }); | |
| updates.push(...deleteClassPropTypes(node, sourceFile)); | |
| } | |
| } | |
| } | |
| return updates; | |
| } | |
| function isEmptyTypeLiteral(node: ts.Node) { | |
| return ts.isTypeLiteralNode(node) && node.members.length === 0; | |
| } | |
| function updateObjectLiteral( | |
| node: ReactNode, | |
| objectLiteral: ts.ObjectLiteralExpression, | |
| propsTypeName: string, | |
| sourceFile: ts.SourceFile, | |
| options: Options, | |
| implicitChildren: boolean, | |
| ) { | |
| const updates: SourceTextUpdate[] = []; | |
| const printer = ts.createPrinter(); | |
| const propsTypeNode = getTypeFromPropTypesObjectLiteral(objectLiteral, sourceFile, { | |
| anyAlias: options.anyAlias, | |
| anyFunctionAlias: options.anyFunctionAlias, | |
| implicitChildren, | |
| spreadReplacements, | |
| }); | |
| let propsTypeAlias = ts.createTypeAliasDeclaration( | |
| undefined, | |
| undefined, | |
| propsTypeName, | |
| undefined, | |
| propsTypeNode, | |
| ); | |
| propsTypeAlias = ts.moveSyntheticComments(propsTypeAlias, propsTypeNode); | |
| const varStatement = getParentVariableStatement(objectLiteral, sourceFile); | |
| if (varStatement) { | |
| updates.push({ | |
| kind: 'replace', | |
| index: varStatement.pos, | |
| length: varStatement.end - varStatement.pos, | |
| text: getTextPreservingWhitespace(varStatement, propsTypeAlias, sourceFile), | |
| }); | |
| } else { | |
| updates.push({ | |
| kind: 'insert', | |
| index: node.pos, | |
| text: `\n\n${printer.printNode(ts.EmitHint.Unspecified, propsTypeAlias, sourceFile)}`, | |
| }); | |
| } | |
| return updates; | |
| } | |
| function getComponentName(node: ReactNode) { | |
| if (ts.isClassDeclaration(node) || ts.isFunctionDeclaration(node)) { | |
| return node.name && node.name.text; | |
| } | |
| if (ts.isVariableStatement(node)) { | |
| const declaration = node.declarationList.declarations[0]; | |
| return declaration && declaration.name && ts.isIdentifier(declaration.name) | |
| ? declaration.name.text | |
| : undefined; | |
| } | |
| return undefined; | |
| } | |
| function getPropsParam(node: ReactSfcNode) { | |
| if (ts.isFunctionDeclaration(node)) { | |
| return node.parameters[0]; | |
| } | |
| if (ts.isVariableStatement(node)) { | |
| const declaration = node.declarationList.declarations[0]; | |
| const init = declaration && declaration.initializer; | |
| const arrowFunction = init && ts.isArrowFunction(init) ? init : undefined; | |
| return arrowFunction && arrowFunction.parameters[0]; | |
| } | |
| return undefined; | |
| } | |
| function getParentVariableStatement( | |
| objectLiteral: ts.ObjectLiteralExpression, | |
| sourceFile: ts.SourceFile, | |
| ): ts.VariableStatement | undefined { | |
| let cur: ts.Node = objectLiteral; | |
| while (cur !== sourceFile) { | |
| if (ts.isVariableStatement(cur)) { | |
| return cur; | |
| } | |
| cur = cur.parent; | |
| } | |
| return undefined; | |
| } | |
| function deleteClassPropTypes(classDeclaration: ts.ClassDeclaration, sourceFile: ts.SourceFile) { | |
| const updates: SourceTextUpdate[] = []; | |
| for (const member of classDeclaration.members) { | |
| if (isPropTypesStatic(member)) { | |
| updates.push({ | |
| kind: 'delete', | |
| index: member.pos, | |
| length: member.end - member.pos, | |
| }); | |
| if (member.initializer && ts.isIdentifier(member.initializer)) { | |
| updates.push(...deleteIdRef(member.initializer, sourceFile)); | |
| } | |
| } | |
| } | |
| const className = classDeclaration.name && classDeclaration.name.text; | |
| if (className) { | |
| updates.push(...deletePropTypesStatements(className, sourceFile)); | |
| } | |
| return updates; | |
| } | |
| function deleteSfcPropTypes(node: ReactSfcNode, sourceFile: ts.SourceFile) { | |
| const componentName = getComponentName(node); | |
| return componentName ? deletePropTypesStatements(componentName, sourceFile) : []; | |
| } | |
| function deletePropTypesStatements(componentName: string, sourceFile: ts.SourceFile) { | |
| const updates: SourceTextUpdate[] = []; | |
| for (const statement of sourceFile.statements) { | |
| if (isPropTypesStatement(statement, componentName)) { | |
| updates.push({ | |
| kind: 'delete', | |
| index: statement.pos, | |
| length: statement.end - statement.pos, | |
| }); | |
| if ( | |
| ts.isBinaryExpression(statement.expression) && | |
| ts.isIdentifier(statement.expression.right) | |
| ) { | |
| updates.push(...deleteIdRef(statement.expression.right, sourceFile)); | |
| } | |
| } | |
| } | |
| return updates; | |
| } | |
| function deleteIdRef(idenifier: ts.Identifier, sourceFile: ts.SourceFile) { | |
| const updates: SourceTextUpdate[] = []; | |
| for (const statement of sourceFile.statements) { | |
| if (ts.isVariableDeclarationList(statement) && statement.declarations.length === 1) { | |
| const declaration = statement.declarations[0]; | |
| if ( | |
| ts.isVariableDeclaration(declaration) && | |
| ts.isIdentifier(declaration.name) && | |
| declaration.name.text === idenifier.text | |
| ) { | |
| if (declaration.initializer && ts.isIdentifier(declaration.initializer)) { | |
| updates.push( | |
| { | |
| kind: 'delete', | |
| index: declaration.pos, | |
| length: declaration.end - declaration.pos, | |
| }, | |
| ...deleteIdRef(declaration.initializer, sourceFile), | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| return updates; | |
| } | |
| function isPropTypesStatic(member: ts.ClassElement): member is ts.PropertyDeclaration { | |
| return ( | |
| ts.isPropertyDeclaration(member) && | |
| member.modifiers != null && | |
| member.modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) && | |
| ts.isIdentifier(member.name) && | |
| member.name.text === 'propTypes' && | |
| member.initializer != null | |
| ); | |
| } | |
| function isPropTypesStatement( | |
| statement: ts.Statement, | |
| componentName: string, | |
| ): statement is ts.ExpressionStatement { | |
| return ( | |
| ts.isExpressionStatement(statement) && | |
| ts.isBinaryExpression(statement.expression) && | |
| ts.isPropertyAccessExpression(statement.expression.left) && | |
| ts.isIdentifier(statement.expression.left.expression) && | |
| statement.expression.left.expression.text === componentName && | |
| ts.isIdentifier(statement.expression.left.name) && | |
| statement.expression.left.name.text === 'propTypes' | |
| ); | |
| } | |
| function findClassPropTypesNode( | |
| classDeclaration: ts.ClassDeclaration, | |
| sourceFile: ts.SourceFile, | |
| ): ts.PropertyDeclaration | ts.ExpressionStatement | undefined { | |
| for (const member of classDeclaration.members) { | |
| if (isPropTypesStatic(member)) { | |
| return member; | |
| } | |
| } | |
| const componentName = classDeclaration.name && classDeclaration.name.text; | |
| for (const statement of sourceFile.statements) { | |
| if (componentName && isPropTypesStatement(statement, componentName)) { | |
| return statement; | |
| } | |
| } | |
| return undefined; | |
| } | |
| function findSfcPropTypesNode( | |
| node: ReactSfcNode, | |
| sourceFile: ts.SourceFile, | |
| ): ts.PropertyDeclaration | ts.ExpressionStatement | undefined { | |
| const componentName = getComponentName(node); | |
| for (const statement of sourceFile.statements) { | |
| if (componentName && isPropTypesStatement(statement, componentName)) { | |
| return statement; | |
| } | |
| } | |
| return undefined; | |
| } | |
| function findPropTypesObjectLiteral( | |
| node: ts.PropertyDeclaration | ts.ExpressionStatement | undefined, | |
| sourceFile: ts.SourceFile, | |
| ): ts.ObjectLiteralExpression | undefined { | |
| if (!node) return undefined; | |
| let expression: ts.Expression | undefined; | |
| if (ts.isPropertyDeclaration(node) && node.initializer != null) { | |
| expression = node.initializer; | |
| } else if (ts.isExpressionStatement(node) && ts.isBinaryExpression(node.expression)) { | |
| expression = node.expression.right; | |
| } | |
| return unpackInitializer(expression, sourceFile); | |
| } | |
| function unpackInitializer( | |
| initializer: ts.Expression | undefined, | |
| sourceFile: ts.SourceFile, | |
| ): ts.ObjectLiteralExpression | undefined { | |
| if (!initializer) { | |
| return undefined; | |
| } | |
| if (ts.isObjectLiteralExpression(initializer)) { | |
| return initializer; | |
| } | |
| if ( | |
| ts.isCallExpression(initializer) && | |
| ts.isIdentifier(initializer.expression) && | |
| initializer.expression.text === 'forbidExtraProps' && | |
| initializer.arguments.length === 1 | |
| ) { | |
| const arg = initializer.arguments[0]; | |
| if (ts.isObjectLiteralExpression(arg)) { | |
| return arg; | |
| } | |
| } | |
| if (ts.isIdentifier(initializer)) { | |
| for (const statement of sourceFile.statements) { | |
| if ( | |
| ts.isVariableStatement(statement) && | |
| statement.declarationList.declarations.length === 1 | |
| ) { | |
| const declaration = statement.declarationList.declarations[0]; | |
| if ( | |
| ts.isVariableDeclaration(declaration) && | |
| ts.isIdentifier(declaration.name) && | |
| declaration.name.text === initializer.text | |
| ) { | |
| return unpackInitializer(declaration.initializer, sourceFile); | |
| } | |
| } | |
| } | |
| } | |
| return undefined; | |
| } |