diff --git a/src/compiler.ts b/src/compiler.ts index ee4c64c..acfce10 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -14,7 +14,10 @@ export function compile(filePath: string, factoryFactories: TransformFactoryFact }; const program = ts.createProgram([filePath], compilerOptions); - const sourceFiles = program.getSourceFiles().filter(sf => !sf.isDeclarationFile); + // `program.getSourceFiles()` will include those imported files, + // like: `import * as a from './file-a'`. + // We should only transform current file. + const sourceFiles = program.getSourceFiles().filter(sf => sf.fileName === filePath); const typeChecker = program.getTypeChecker(); const result = ts.transform( diff --git a/src/helpers/build-prop-type-interface.ts b/src/helpers/build-prop-type-interface.ts new file mode 100644 index 0000000..df0730a --- /dev/null +++ b/src/helpers/build-prop-type-interface.ts @@ -0,0 +1,188 @@ +import * as ts from 'typescript'; + +/** + * Build props interface from propTypes object + * @example + * { + * foo: React.PropTypes.string.isRequired + * } + * + * becomes + * { + * foo: string; + * } + * @param objectLiteral + */ +export function buildInterfaceFromPropTypeObjectLiteral(objectLiteral: ts.ObjectLiteralExpression) { + const members = objectLiteral.properties + // We only need to process PropertyAssignment: + // { + // a: 123 // PropertyAssignment + // } + // + // filter out: + // { + // a() {}, // MethodDeclaration + // b, // ShorthandPropertyAssignment + // ...c, // SpreadAssignment + // get d() {}, // AccessorDeclaration + // } + .filter(ts.isPropertyAssignment) + // Ignore children, React types have it + .filter(property => property.name.getText() !== 'children') + .map(propertyAssignment => { + const name = propertyAssignment.name.getText(); + const initializer = propertyAssignment.initializer; + const isRequired = isPropTypeRequired(initializer); + const typeExpression = isRequired + ? // We have guaranteed the type in `isPropTypeRequired()` + (initializer as ts.PropertyAccessExpression).expression + : initializer; + const typeValue = getTypeFromReactPropTypeExpression(typeExpression); + + return ts.createPropertySignature( + [], + name, + isRequired ? undefined : ts.createToken(ts.SyntaxKind.QuestionToken), + typeValue, + undefined, + ); + }); + + return ts.createTypeLiteralNode(members); +} + +/** + * Turns React.PropTypes.* into TypeScript type value + * + * @param node React propTypes value + */ +function getTypeFromReactPropTypeExpression(node: ts.Expression): ts.TypeNode { + let result = null; + if (ts.isPropertyAccessExpression(node)) { + /** + * PropTypes.array, + * PropTypes.bool, + * PropTypes.func, + * PropTypes.number, + * PropTypes.object, + * PropTypes.string, + * PropTypes.symbol, (ignore) + * PropTypes.node, + * PropTypes.element, + * PropTypes.any, + */ + const text = node.getText().replace(/React\.PropTypes\./, ''); + + if (/string/.test(text)) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); + } else if (/any/.test(text)) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); + } else if (/array/.test(text)) { + result = ts.createArrayTypeNode(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); + } else if (/bool/.test(text)) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); + } else if (/number/.test(text)) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); + } else if (/object/.test(text)) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword); + } else if (/node/.test(text)) { + result = ts.createTypeReferenceNode('React.ReactNode', []); + } else if (/element/.test(text)) { + result = ts.createTypeReferenceNode('JSX.Element', []); + } else if (/func/.test(text)) { + const arrayOfAny = ts.createParameter( + [], + [], + ts.createToken(ts.SyntaxKind.DotDotDotToken), + 'args', + undefined, + ts.createArrayTypeNode(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)), + undefined, + ); + result = ts.createFunctionTypeNode([], [arrayOfAny], ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); + } + } else if (ts.isCallExpression(node)) { + /** + * PropTypes.instanceOf(), (ignore) + * PropTypes.oneOf(), // only support oneOf([1, 2]), oneOf(['a', 'b']) + * PropTypes.oneOfType(), + * PropTypes.arrayOf(), + * PropTypes.objectOf(), + * PropTypes.shape(), + */ + const text = node.expression.getText(); + if (/oneOf$/.test(text)) { + const argument = node.arguments[0]; + if (ts.isArrayLiteralExpression(argument)) { + if (argument.elements.every(elm => ts.isStringLiteral(elm) || ts.isNumericLiteral(elm))) { + result = ts.createUnionTypeNode( + (argument.elements as ts.NodeArray).map(elm => + ts.createLiteralTypeNode(elm), + ), + ); + } + } + } else if (/oneOfType$/.test(text)) { + const argument = node.arguments[0]; + if (ts.isArrayLiteralExpression(argument)) { + result = ts.createUnionOrIntersectionTypeNode( + ts.SyntaxKind.UnionType, + argument.elements.map(elm => getTypeFromReactPropTypeExpression(elm)), + ); + } + } else if (/arrayOf$/.test(text)) { + const argument = node.arguments[0]; + if (argument) { + result = ts.createArrayTypeNode(getTypeFromReactPropTypeExpression(argument)); + } + } else if (/objectOf$/.test(text)) { + const argument = node.arguments[0]; + if (argument) { + result = ts.createTypeLiteralNode([ + ts.createIndexSignature( + undefined, + undefined, + [ + ts.createParameter( + undefined, + undefined, + undefined, + 'key', + undefined, + ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ), + ], + getTypeFromReactPropTypeExpression(argument), + ), + ]); + } + } else if (/shape$/.test(text)) { + const argument = node.arguments[0]; + if (ts.isObjectLiteralExpression(argument)) { + return buildInterfaceFromPropTypeObjectLiteral(argument); + } + } + } + + /** + * customProp, + * anything others + */ + if (result === null) { + result = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); + } + + return result; +} + +/** + * Decide if node is required + * @param node React propTypes member node + */ +function isPropTypeRequired(node: ts.Expression) { + if (!ts.isPropertyAccessExpression(node)) return false; + + const text = node.getText().replace(/React\.PropTypes\./, ''); + return /\.isRequired/.test(text); +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 243bdbf..1018c5d 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,6 +1,8 @@ import * as ts from 'typescript'; import * as _ from 'lodash'; +export * from './build-prop-type-interface'; + /** * If a class declaration a react class? * @param classDeclaration @@ -50,10 +52,12 @@ export function isReactComponent(classDeclaration: ts.ClassDeclaration, typeChec * @param clause */ export function isReactHeritageClause(clause: ts.HeritageClause) { - return clause.token === ts.SyntaxKind.ExtendsKeyword && + return ( + clause.token === ts.SyntaxKind.ExtendsKeyword && clause.types.length === 1 && ts.isExpressionWithTypeArguments(clause.types[0]) && - /Component/.test(clause.types[0].expression.getText()); + /Component/.test(clause.types[0].expression.getText()) + ); } /** @@ -63,11 +67,13 @@ export function isReactHeritageClause(clause: ts.HeritageClause) { * @param statement */ export function isReactPropTypeAssignmentStatement(statement: ts.Statement): statement is ts.ExpressionStatement { - return ts.isExpressionStatement(statement) - && ts.isBinaryExpression(statement.expression) - && statement.expression.operatorToken.kind === ts.SyntaxKind.FirstAssignment - && ts.isPropertyAccessExpression(statement.expression.left) - && /\.propTypes$|\.propTypes\..+$/.test(statement.expression.left.getText()) + return ( + ts.isExpressionStatement(statement) && + ts.isBinaryExpression(statement.expression) && + statement.expression.operatorToken.kind === ts.SyntaxKind.FirstAssignment && + ts.isPropertyAccessExpression(statement.expression.left) && + /\.propTypes$|\.propTypes\..+$/.test(statement.expression.left.getText()) + ); } /** @@ -78,7 +84,7 @@ export function hasStaticModifier(classMember: ts.ClassElement) { if (!classMember.modifiers) { return false; } - const staticModifier = _.find(classMember.modifiers, (modifier) => { + const staticModifier = _.find(classMember.modifiers, modifier => { return modifier.kind == ts.SyntaxKind.StaticKeyword; }); return staticModifier !== undefined; @@ -91,12 +97,61 @@ export function hasStaticModifier(classMember: ts.ClassElement) { */ export function isPropTypesMember(classMember: ts.ClassElement, sourceFile: ts.SourceFile) { try { - return classMember.name !== undefined && classMember.name.getFullText(sourceFile) !== 'propTypes' + return classMember.name !== undefined && classMember.name.getFullText(sourceFile) !== 'propTypes'; } catch (e) { return false; } } +/** + * Get component name off of a propType assignment statement + * @param propTypeAssignment + * @param sourceFile + */ +export function getComponentName(propTypeAssignment: ts.Statement, sourceFile: ts.SourceFile) { + const text = propTypeAssignment.getText(sourceFile); + return text.substr(0, text.indexOf('.')); +} + +/** + * Convert react stateless function to arrow function + * @example + * Before: + * function Hello(message) { + * return
{message}
+ * } + * + * After: + * const Hello = message => { + * return
{message}
+ * } + */ +export function convertReactStatelessFunctionToArrowFunction( + statelessFunc: ts.FunctionDeclaration | ts.VariableStatement, +) { + if (ts.isVariableStatement(statelessFunc)) return statelessFunc; + + const funcName = statelessFunc.name || 'Component'; + const funcBody = statelessFunc.body || ts.createBlock([]); + + const initializer = ts.createArrowFunction( + undefined, + undefined, + statelessFunc.parameters, + undefined, + undefined, + funcBody, + ); + + return ts.createVariableStatement( + statelessFunc.modifiers, + ts.createVariableDeclarationList( + [ts.createVariableDeclaration(funcName, undefined, initializer)], + ts.NodeFlags.Const, + ), + ); +} + /** * Insert an item in middle of an array after a specific item * @param collection diff --git a/src/index.ts b/src/index.ts index 7e824e0..3915006 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,16 +2,16 @@ import * as ts from 'typescript'; import { compile } from './compiler'; import { reactJSMakePropsAndStateInterfaceTransformFactoryFactory } from './transforms/react-js-make-props-and-state-transform'; -import { reactHoistGenericsTransformFactoryFactory } from './transforms/react-hoist-generics-transform'; import { reactRemovePropTypesAssignmentTransformFactoryFactory } from './transforms/react-remove-prop-types-assignment-transform'; import { reactMovePropTypesToClassTransformFactoryFactory } from './transforms/react-move-prop-types-to-class-transform'; import { collapseIntersectionInterfacesTransformFactoryFactory } from './transforms/collapse-intersection-interfaces-transform'; import { reactRemoveStaticPropTypesMemberTransformFactoryFactory } from './transforms/react-remove-static-prop-types-member-transform'; +import { reactStatelessFunctionMakePropsTransformFactoryFactory } from './transforms/react-stateless-function-make-props-transform'; export { reactMovePropTypesToClassTransformFactoryFactory, reactJSMakePropsAndStateInterfaceTransformFactoryFactory, - reactHoistGenericsTransformFactoryFactory, + reactStatelessFunctionMakePropsTransformFactoryFactory, collapseIntersectionInterfacesTransformFactoryFactory, reactRemovePropTypesAssignmentTransformFactoryFactory, reactRemoveStaticPropTypesMemberTransformFactoryFactory, @@ -21,7 +21,7 @@ export { export const allTransforms = [ reactMovePropTypesToClassTransformFactoryFactory, reactJSMakePropsAndStateInterfaceTransformFactoryFactory, - reactHoistGenericsTransformFactoryFactory, + reactStatelessFunctionMakePropsTransformFactoryFactory, collapseIntersectionInterfacesTransformFactoryFactory, reactRemovePropTypesAssignmentTransformFactoryFactory, reactRemoveStaticPropTypesMemberTransformFactoryFactory, diff --git a/src/transforms/react-hoist-generics-transform.ts b/src/transforms/react-hoist-generics-transform.ts deleted file mode 100644 index 768517c..0000000 --- a/src/transforms/react-hoist-generics-transform.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as ts from 'typescript'; -import * as _ from 'lodash'; - -import * as helpers from '../helpers'; - -export type Factory = ts.TransformerFactory; - -/** - * Hoist generics to top of a class declarations in a React component - * - * @example - * Before: - * class SomeComponent extends React.Component<{foo: number;}, {bar: string;}> {} - * - * After - * type SomeComponentProps = {foo: number;}; - * type SomeComponentState = {bar: string;}; - * class SomeComponent extends React.Component {} - */ -export function reactHoistGenericsTransformFactoryFactory(typeChecker: ts.TypeChecker): Factory { - return function reactHoistGenericsTransformFactory(context: ts.TransformationContext) { - return function reactHoistGenericsTransform(node: ts.SourceFile) { - return visitSourceFile(node); - }; - }; - - function visitSourceFile(sourceFile: ts.SourceFile) { - - for (const statement of sourceFile.statements) { - if (ts.isClassDeclaration(statement) && helpers.isReactComponent(statement, typeChecker)) { - return hoist(statement, sourceFile); - } - } - - return sourceFile; - } -} - -/** - * Hoist props and state generic types - * @param reactClass - * @param sourceFile - */ -function hoist(reactClass: ts.ClassDeclaration, sourceFile: ts.SourceFile) { - if (!reactClass.heritageClauses) { - return sourceFile; - } - const className = reactClass && reactClass.name && reactClass.name.getText(sourceFile); - const reactHeritageClauses = _.find(reactClass.heritageClauses, helpers.isReactHeritageClause); - - if (reactHeritageClauses === undefined || !reactHeritageClauses.types == undefined) { - return sourceFile; - } - const [reactType] = reactHeritageClauses.types; - if (reactType.typeArguments === undefined || reactType.typeArguments.length < 2) { - return sourceFile; - } - - const [propType, stateType] = reactType.typeArguments; - const propTypeName = `${className}Props`; - const stateTypeName = `${className}State`; - const propTypeDeclaration = ts.createTypeAliasDeclaration([], [], propTypeName, [], propType); - const stateTypeDeclaration = ts.createTypeAliasDeclaration([], [], stateTypeName, [], stateType); - const propTypeRef = ts.createTypeReferenceNode(propTypeName, []); - const stateTypeRef = ts.createTypeReferenceNode(stateTypeName, []); - const newClassStatement = insertTypeRefs(reactClass, propTypeRef, stateTypeRef); - - let statements = helpers.insertBefore(sourceFile.statements, reactClass, propTypeDeclaration) - statements = helpers.insertAfter(statements, propTypeDeclaration, stateTypeDeclaration); - statements = helpers.replaceItem(statements, reactClass, newClassStatement); - - return ts.updateSourceFileNode(sourceFile, statements); -} - -/** - * Replace props and state types in a React component with type references - * - * @example - * input - * ``` - * class MyComp extends React.Component<{}, {}> {} - * ``` - * - * output - * ``` - * class MyComp extends React.Component {} - * ``` - * - * @param reactClassDeclaration A React class declaration - * @param propTypeRef React Props type reference - * @param stateTypeRef React State type reference - */ -function insertTypeRefs( - reactClassDeclaration: ts.ClassDeclaration, - propTypeRef: ts.TypeReferenceNode, - stateTypeRef: ts.TypeReferenceNode, -) { - if (reactClassDeclaration.heritageClauses === undefined) { - return reactClassDeclaration; - } - const reactHeritageClause = _.find(reactClassDeclaration.heritageClauses, helpers.isReactHeritageClause); - - if (reactHeritageClause === undefined) { - return reactClassDeclaration; - } - - const [reactExpression] = reactHeritageClause.types; - const newReactExpression = ts.updateExpressionWithTypeArguments( - reactExpression, - [propTypeRef, stateTypeRef], - reactExpression.expression, - ); - const newHeritageClauses = ts.updateHeritageClause(reactHeritageClause, [newReactExpression]); - - return ts.updateClassDeclaration( - reactClassDeclaration, - reactClassDeclaration.decorators, - reactClassDeclaration.modifiers, - reactClassDeclaration.name, - reactClassDeclaration.typeParameters, - helpers.replaceItem(reactClassDeclaration.heritageClauses, reactHeritageClause, newHeritageClauses), - reactClassDeclaration.members, - ); -} diff --git a/src/transforms/react-js-make-props-and-state-transform.ts b/src/transforms/react-js-make-props-and-state-transform.ts index e6dd233..3bb3169 100644 --- a/src/transforms/react-js-make-props-and-state-transform.ts +++ b/src/transforms/react-js-make-props-and-state-transform.ts @@ -12,31 +12,64 @@ export type Factory = ts.TransformerFactory; export function reactJSMakePropsAndStateInterfaceTransformFactoryFactory(typeChecker: ts.TypeChecker): Factory { return function reactJSMakePropsAndStateInterfaceTransformFactory(context: ts.TransformationContext) { return function reactJSMakePropsAndStateInterfaceTransform(sourceFile: ts.SourceFile) { - const visited = ts.visitEachChild(sourceFile, visitor, context); + const visited = visitSourceFile(sourceFile, typeChecker); ts.addEmitHelpers(visited, context.readEmitHelpers()); return visited; + }; + }; +} - function visitor(node: ts.Node) { - if (ts.isClassDeclaration(node)) { - return visitClassDeclaration(node, sourceFile, typeChecker); - } - - return node; - } +function visitSourceFile(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) { + for (const statement of sourceFile.statements) { + if (ts.isClassDeclaration(statement) && helpers.isReactComponent(statement, typeChecker)) { + return visitReactClassDeclaration(statement, sourceFile, typeChecker); } } + + return sourceFile; } -function visitClassDeclaration( +function visitReactClassDeclaration( classDeclaration: ts.ClassDeclaration, sourceFile: ts.SourceFile, - typeChecker: ts.TypeChecker + typeChecker: ts.TypeChecker, ) { - if (!helpers.isReactComponent(classDeclaration, typeChecker)) { - return classDeclaration; + if (!classDeclaration.heritageClauses || !classDeclaration.heritageClauses.length) { + return sourceFile; } + const className = classDeclaration && classDeclaration.name && classDeclaration.name.getText(sourceFile); + const propType = getPropsTypeOfReactComponentClass(classDeclaration, sourceFile); + const stateType = getStateTypeOfReactComponentClass(classDeclaration, typeChecker); + const shouldMakePropTypeDeclaration = propType.members.length > 0; + const shouldMakeStateTypeDeclaration = !isStateTypeMemberEmpty(stateType); + const propTypeName = `${className}Props`; + const stateTypeName = `${className}State`; + const propTypeDeclaration = ts.createTypeAliasDeclaration([], [], propTypeName, [], propType); + const stateTypeDeclaration = ts.createTypeAliasDeclaration([], [], stateTypeName, [], stateType); + const propTypeRef = ts.createTypeReferenceNode(propTypeName, []); + const stateTypeRef = ts.createTypeReferenceNode(stateTypeName, []); + + const newClassDeclaration = getNewReactClassDeclaration( + classDeclaration, + shouldMakePropTypeDeclaration ? propTypeRef : propType, + shouldMakeStateTypeDeclaration ? stateTypeRef : stateType, + ); + + const allTypeDeclarations = []; + if (shouldMakePropTypeDeclaration) allTypeDeclarations.push(propTypeDeclaration); + if (shouldMakeStateTypeDeclaration) allTypeDeclarations.push(stateTypeDeclaration); + let statements = helpers.insertBefore(sourceFile.statements, classDeclaration, allTypeDeclarations); + statements = helpers.replaceItem(statements, classDeclaration, newClassDeclaration); + return ts.updateSourceFileNode(sourceFile, statements); +} + +function getNewReactClassDeclaration( + classDeclaration: ts.ClassDeclaration, + propTypeRef: ts.TypeNode, + stateTypeRef: ts.TypeNode, +) { if (!classDeclaration.heritageClauses || !classDeclaration.heritageClauses.length) { return classDeclaration; } @@ -48,10 +81,7 @@ function visitClassDeclaration( firstHeritageClause.types[0], ts.updateExpressionWithTypeArguments( firstHeritageClause.types[0], - [ - getPropsTypeOfReactComponentClass(classDeclaration, sourceFile), - getStateTypeOfReactComponentClass(classDeclaration, typeChecker), - ], + [propTypeRef, stateTypeRef], firstHeritageClause.types[0].expression, ), ); @@ -76,45 +106,46 @@ function visitClassDeclaration( function getPropsTypeOfReactComponentClass( classDeclaration: ts.ClassDeclaration, sourceFile: ts.SourceFile, -): ts.TypeNode { - const staticPropTypesMember = _.find(classDeclaration.members, (member) => { - return ts.isPropertyDeclaration(member) && +): ts.TypeLiteralNode { + const staticPropTypesMember = _.find(classDeclaration.members, member => { + return ( + ts.isPropertyDeclaration(member) && helpers.hasStaticModifier(member) && - helpers.isPropTypesMember(member, sourceFile); + helpers.isPropTypesMember(member, sourceFile) + ); }); if ( - staticPropTypesMember !== undefined - && ts.isPropertyDeclaration(staticPropTypesMember) // check to satisfy type checker - && staticPropTypesMember.initializer - && ts.isObjectLiteralExpression(staticPropTypesMember.initializer) + staticPropTypesMember !== undefined && + ts.isPropertyDeclaration(staticPropTypesMember) && // check to satisfy type checker + staticPropTypesMember.initializer && + ts.isObjectLiteralExpression(staticPropTypesMember.initializer) ) { - return buildInterfaceFromPropTypeObjectLiteral(staticPropTypesMember.initializer) + return helpers.buildInterfaceFromPropTypeObjectLiteral(staticPropTypesMember.initializer); } - const staticPropTypesGetterMember = _.find(classDeclaration.members, (member) => { - return ts.isGetAccessorDeclaration(member) && + const staticPropTypesGetterMember = _.find(classDeclaration.members, member => { + return ( + ts.isGetAccessorDeclaration(member) && helpers.hasStaticModifier(member) && - helpers.isPropTypesMember(member, sourceFile); + helpers.isPropTypesMember(member, sourceFile) + ); }); if ( - staticPropTypesGetterMember !== undefined - && ts.isGetAccessorDeclaration(staticPropTypesGetterMember) // check to satisfy typechecker + staticPropTypesGetterMember !== undefined && + ts.isGetAccessorDeclaration(staticPropTypesGetterMember) // check to satisfy typechecker ) { - const returnStatement = _.find( - staticPropTypesGetterMember.body.statements, - (statement) => ts.isReturnStatement(statement), + const returnStatement = _.find(staticPropTypesGetterMember.body.statements, statement => + ts.isReturnStatement(statement), ); if ( - returnStatement !== undefined - && ts.isReturnStatement(returnStatement) // check to satisfy typechecker - && returnStatement.expression - && ts.isObjectLiteralExpression(returnStatement.expression) + returnStatement !== undefined && + ts.isReturnStatement(returnStatement) && // check to satisfy typechecker + returnStatement.expression && + ts.isObjectLiteralExpression(returnStatement.expression) ) { - return buildInterfaceFromPropTypeObjectLiteral( - returnStatement.expression - ) + return helpers.buildInterfaceFromPropTypeObjectLiteral(returnStatement.expression); } } @@ -132,7 +163,7 @@ function getStateTypeOfReactComponentClass( return ts.createTypeLiteralNode([]); } if (!initialStateIsVoid) { - collectedStateTypes.push(initialState) + collectedStateTypes.push(initialState); } return ts.createUnionOrIntersectionTypeNode(ts.SyntaxKind.IntersectionType, collectedStateTypes); @@ -149,30 +180,24 @@ function getInitialStateFromClassDeclaration( ): ts.TypeNode { // initial state class member - const initialStateMember = _.find(classDeclaration.members, (member) => { + const initialStateMember = _.find(classDeclaration.members, member => { try { - return ts.isPropertyDeclaration(member) && - member.name && - member.name.getText() === 'state'; - } catch(e) { + return ts.isPropertyDeclaration(member) && member.name && member.name.getText() === 'state'; + } catch (e) { return false; } }); - if (initialStateMember - && ts.isPropertyDeclaration(initialStateMember) - && initialStateMember.initializer - ) { - const type = typeChecker.getTypeAtLocation(initialStateMember.initializer)! + if (initialStateMember && ts.isPropertyDeclaration(initialStateMember) && initialStateMember.initializer) { + const type = typeChecker.getTypeAtLocation(initialStateMember.initializer)!; return typeChecker.typeToTypeNode(type); } // Initial state in constructor - const constructor = _.find( - classDeclaration.members, - (member) => member.kind === ts.SyntaxKind.Constructor, - ) as ts.ConstructorDeclaration | undefined; + const constructor = _.find(classDeclaration.members, member => member.kind === ts.SyntaxKind.Constructor) as + | ts.ConstructorDeclaration + | undefined; if (constructor && constructor.body) { for (const statement of constructor.body.statements) { @@ -181,9 +206,7 @@ function getInitialStateFromClassDeclaration( ts.isBinaryExpression(statement.expression) && statement.expression.left.getText() === 'this.state' ) { - return typeChecker.typeToTypeNode( - typeChecker.getTypeAtLocation(statement.expression.right) - ); + return typeChecker.typeToTypeNode(typeChecker.getTypeAtLocation(statement.expression.right)); } } } @@ -204,214 +227,34 @@ function getStateLookingForSetStateCalls( const typeNodes: ts.TypeNode[] = []; for (const member of classDeclaration.members) { if (member && ts.isMethodDeclaration(member) && member.body) { - lookForSetState(member.body) + lookForSetState(member.body); } } return typeNodes; function lookForSetState(node: ts.Node) { - ts.forEachChild(node, lookForSetState) + ts.forEachChild(node, lookForSetState); if ( ts.isExpressionStatement(node) && ts.isCallExpression(node.expression) && node.expression.expression.getText().match(/setState/) ) { - const type = typeChecker.getTypeAtLocation(node.expression.arguments[0]) + const type = typeChecker.getTypeAtLocation(node.expression.arguments[0]); typeNodes.push(typeChecker.typeToTypeNode(type)); } } } -/** - * Build props interface from propTypes object - * @example - * { - * foo: React.PropTypes.string.isRequired - * } - * - * becomes - * { - * foo: string; - * } - * @param objectLiteral - */ -function buildInterfaceFromPropTypeObjectLiteral(objectLiteral: ts.ObjectLiteralExpression) { - const members = objectLiteral.properties - // We only need to process PropertyAssignment: - // { - // a: 123 // PropertyAssignment - // } - // - // filter out: - // { - // a() {}, // MethodDeclaration - // b, // ShorthandPropertyAssignment - // ...c, // SpreadAssignment - // get d() {}, // AccessorDeclaration - // } - .filter(ts.isPropertyAssignment) - // Ignore children, React types have it - .filter(property => property.name.getText() !== 'children') - .map(propertyAssignment => { - const name = propertyAssignment.name.getText(); - const initializer = propertyAssignment.initializer; - const isRequired = isPropTypeRequired(initializer); - const typeExpression = isRequired - // We have guaranteed the type in `isPropTypeRequired()` - ? (initializer as ts.PropertyAccessExpression).expression - : initializer; - const typeValue = getTypeFromReactPropTypeExpression(typeExpression); - - return ts.createPropertySignature( - [], - name, - isRequired ? undefined : ts.createToken(ts.SyntaxKind.QuestionToken), - typeValue, - undefined, - ); - }); - - return ts.createTypeLiteralNode(members) -} - -/** - * Turns React.PropTypes.* into TypeScript type value - * - * @param node React propTypes value - */ -function getTypeFromReactPropTypeExpression(node: ts.Expression): ts.TypeNode { - let result = null; - if (ts.isPropertyAccessExpression(node)) { - /** - * PropTypes.array, - * PropTypes.bool, - * PropTypes.func, - * PropTypes.number, - * PropTypes.object, - * PropTypes.string, - * PropTypes.symbol, (ignore) - * PropTypes.node, - * PropTypes.element, - * PropTypes.any, - */ - const text = node.getText().replace(/React\.PropTypes\./, ''); - - if (/string/.test(text)) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - } else if (/any/.test(text)) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); - } else if (/array/.test(text)) { - result = ts.createArrayTypeNode(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)); - } else if (/bool/.test(text)) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - } else if (/number/.test(text)) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - } else if (/object/.test(text)) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.ObjectKeyword); - } else if (/node/.test(text)) { - result = ts.createTypeReferenceNode('React.ReactNode', []); - } else if (/element/.test(text)) { - result = ts.createTypeReferenceNode('JSX.Element', []); - } else if (/func/.test(text)) { - const arrayOfAny = ts.createParameter( - [], - [], - ts.createToken(ts.SyntaxKind.DotDotDotToken), - 'args', - undefined, - ts.createArrayTypeNode(ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword)), - undefined, - ); - result = ts.createFunctionTypeNode( - [], - [arrayOfAny], - ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword), - ); - } - } else if (ts.isCallExpression(node)) { - /** - * PropTypes.instanceOf(), (ignore) - * PropTypes.oneOf(), // only support oneOf([1, 2]), oneOf(['a', 'b']) - * PropTypes.oneOfType(), - * PropTypes.arrayOf(), - * PropTypes.objectOf(), - * PropTypes.shape(), - */ - const text = node.expression.getText(); - if (/oneOf$/.test(text)) { - const argument = node.arguments[0]; - if (ts.isArrayLiteralExpression(argument)) { - if (argument.elements.every(elm => ts.isStringLiteral(elm) || ts.isNumericLiteral(elm))) { - result = ts.createUnionTypeNode( - (argument.elements as ts.NodeArray).map(elm => - ts.createLiteralTypeNode(elm) - ), - ) - } - } - } else if (/oneOfType$/.test(text)) { - const argument = node.arguments[0]; - if (ts.isArrayLiteralExpression(argument)) { - result = ts.createUnionOrIntersectionTypeNode( - ts.SyntaxKind.UnionType, - argument.elements.map(elm => getTypeFromReactPropTypeExpression(elm)), - ); - } - } else if (/arrayOf$/.test(text)) { - const argument = node.arguments[0]; - if (argument) { - result = ts.createArrayTypeNode( - getTypeFromReactPropTypeExpression(argument) - ) - } - } else if (/objectOf$/.test(text)) { - const argument = node.arguments[0]; - if (argument) { - result = ts.createTypeLiteralNode([ - ts.createIndexSignature( - undefined, - undefined, - [ - ts.createParameter( - undefined, - undefined, - undefined, - 'key', - undefined, - ts.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - ) - ], - getTypeFromReactPropTypeExpression(argument), - ) - ]) - } - } else if (/shape$/.test(text)) { - const argument = node.arguments[0]; - if (ts.isObjectLiteralExpression(argument)) { - return buildInterfaceFromPropTypeObjectLiteral(argument) - } - } +function isStateTypeMemberEmpty(stateType: ts.TypeNode): boolean { + // Only need to handle TypeLiteralNode & IntersectionTypeNode + if (ts.isTypeLiteralNode(stateType)) { + return stateType.members.length === 0; } - /** - * customProp, - * anything others - */ - if (result === null) { - result = ts.createKeywordTypeNode(ts.SyntaxKind.AnyKeyword); + if (!ts.isIntersectionTypeNode(stateType)) { + return true; } - return result -} - -/** - * Decide if node is required - * @param node React propTypes member node - */ -function isPropTypeRequired(node: ts.Expression) { - if (!ts.isPropertyAccessExpression(node)) return false; - - const text = node.getText().replace(/React\.PropTypes\./, ''); - return /\.isRequired/.test(text); + return stateType.types.every(isStateTypeMemberEmpty); } diff --git a/src/transforms/react-move-prop-types-to-class-transform.ts b/src/transforms/react-move-prop-types-to-class-transform.ts index 9eae358..5a7f7b2 100644 --- a/src/transforms/react-move-prop-types-to-class-transform.ts +++ b/src/transforms/react-move-prop-types-to-class-transform.ts @@ -47,22 +47,21 @@ function visitSourceFile(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) let statements = sourceFile.statements; // Look for propType assignment statements - const propTypeAssignments = statements.filter( - (statement) => helpers.isReactPropTypeAssignmentStatement(statement) + const propTypeAssignments = statements.filter(statement => + helpers.isReactPropTypeAssignmentStatement(statement), ) as ts.ExpressionStatement[]; - for (const propTypeAssignment of propTypeAssignments) { - // Look for the class declarations with the same name - const componentName = getComponentName(propTypeAssignment, sourceFile); + const componentName = helpers.getComponentName(propTypeAssignment, sourceFile); - const classStatement = _.find( + const classStatement = (_.find( statements, - (statement) => ts.isClassDeclaration(statement) && + statement => + ts.isClassDeclaration(statement) && statement.name !== undefined && statement.name.getText(sourceFile) === componentName, - ) as {} as ts.ClassDeclaration; // Type weirdness + ) as {}) as ts.ClassDeclaration; // Type weirdness // && helpers.isBinaryExpression(propTypeAssignment.expression) is redundant to satisfy the type checker if (classStatement && ts.isBinaryExpression(propTypeAssignment.expression)) { @@ -80,17 +79,6 @@ function visitSourceFile(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) return ts.updateSourceFileNode(sourceFile, statements); } - -/** - * Get component name off of a propType assignment statement - * @param propTypeAssignment - * @param sourceFile - */ -function getComponentName(propTypeAssignment: ts.Statement, sourceFile: ts.SourceFile) { - const text = propTypeAssignment.getText(sourceFile); - return text.substr(0, text.indexOf('.')); -} - /** * Insert a new static member into a class * @param classDeclaration @@ -98,7 +86,7 @@ function getComponentName(propTypeAssignment: ts.Statement, sourceFile: ts.Sourc * @param value */ function addStaticMemberToClass(classDeclaration: ts.ClassDeclaration, name: string, value: ts.Expression) { - const staticModifier = ts.createToken(ts.SyntaxKind.StaticKeyword) + const staticModifier = ts.createToken(ts.SyntaxKind.StaticKeyword); const propertyDeclaration = ts.createProperty([], [staticModifier], name, undefined, undefined, value); return ts.updateClassDeclaration( classDeclaration, @@ -107,6 +95,6 @@ function addStaticMemberToClass(classDeclaration: ts.ClassDeclaration, name: str classDeclaration.name, classDeclaration.typeParameters, ts.createNodeArray(classDeclaration.heritageClauses), - ts.createNodeArray([propertyDeclaration, ...classDeclaration.members]) - ) + ts.createNodeArray([propertyDeclaration, ...classDeclaration.members]), + ); } diff --git a/src/transforms/react-stateless-function-make-props-transform.ts b/src/transforms/react-stateless-function-make-props-transform.ts new file mode 100644 index 0000000..5be555a --- /dev/null +++ b/src/transforms/react-stateless-function-make-props-transform.ts @@ -0,0 +1,116 @@ +import * as ts from 'typescript'; +import * as _ from 'lodash'; + +import * as helpers from '../helpers'; + +export type Factory = ts.TransformerFactory; + +/** + * Transform react stateless components + * + * @example + * Before: + * const Hello = ({ message }) => { + * return
hello {message}
+ * } + * // Or: + * // const Hello = ({ message }) =>
hello {message}
+ * + * Hello.propTypes = { + * message: React.PropTypes.string, + * } + * + * After: + * Type HelloProps = { + * message: string; + * } + * + * const Hello: React.SFC = ({ message }) => { + * return
hello {message}
+ * } + * + * Hello.propTypes = { + * message: React.PropTypes.string, + * } + */ +export function reactStatelessFunctionMakePropsTransformFactoryFactory(typeChecker: ts.TypeChecker): Factory { + return function reactStatelessFunctionMakePropsTransformFactory(context: ts.TransformationContext) { + return function reactStatelessFunctionMakePropsTransform(sourceFile: ts.SourceFile) { + return visitSourceFile(sourceFile, typeChecker); + }; + }; +} + +function visitSourceFile(sourceFile: ts.SourceFile, typeChecker: ts.TypeChecker) { + let statements = sourceFile.statements; + + // Look for propType assignment statements + const propTypeAssignments = statements.filter(statement => + helpers.isReactPropTypeAssignmentStatement(statement), + ) as ts.ExpressionStatement[]; + + for (const propTypeAssignment of propTypeAssignments) { + const componentName = helpers.getComponentName(propTypeAssignment, sourceFile); + + const funcComponent = (_.find(statements, s => { + return ( + (ts.isFunctionDeclaration(s) && s.name !== undefined && s.name.getText() === componentName) || + (ts.isVariableStatement(s) && s.declarationList.declarations[0].name.getText() === componentName) + ); + }) as {}) as ts.FunctionDeclaration | ts.VariableStatement; // Type weirdness + + if (funcComponent) { + return visitReactStatelessComponent(funcComponent, propTypeAssignment, sourceFile); + } + } + + return sourceFile; +} + +function visitReactStatelessComponent( + component: ts.FunctionDeclaration | ts.VariableStatement, + propTypesExpressionStatement: ts.ExpressionStatement, + sourceFile: ts.SourceFile, +) { + let arrowFuncComponent = helpers.convertReactStatelessFunctionToArrowFunction(component); + let componentName = arrowFuncComponent.declarationList.declarations[0].name.getText(); + let componentInitializer = arrowFuncComponent.declarationList.declarations[0].initializer; + + const propType = getPropTypesFromTypeAssignment(propTypesExpressionStatement); + const shouldMakePropTypeDeclaration = propType.members.length > 0; + const propTypeName = `${componentName}Props`; + const propTypeDeclaration = ts.createTypeAliasDeclaration([], [], propTypeName, [], propType); + const propTypeRef = ts.createTypeReferenceNode(propTypeName, []); + + let componentType = ts.createTypeReferenceNode(ts.createQualifiedName(ts.createIdentifier('React'), 'SFC'), [ + shouldMakePropTypeDeclaration ? propTypeRef : propType, + ]); + + // replace component with ts stateless component + const typedComponent = ts.createVariableStatement( + arrowFuncComponent.modifiers, + ts.createVariableDeclarationList( + [ts.createVariableDeclaration(componentName, componentType, componentInitializer)], + arrowFuncComponent.declarationList.flags, + ), + ); + + let statements = shouldMakePropTypeDeclaration + ? helpers.insertBefore(sourceFile.statements, component, [propTypeDeclaration]) + : sourceFile.statements; + + statements = helpers.replaceItem(statements, component, typedComponent); + return ts.updateSourceFileNode(sourceFile, statements); +} + +function getPropTypesFromTypeAssignment(propTypesExpressionStatement: ts.ExpressionStatement) { + if ( + propTypesExpressionStatement !== undefined && + ts.isBinaryExpression(propTypesExpressionStatement.expression) && + ts.isObjectLiteralExpression(propTypesExpressionStatement.expression.right) + ) { + return helpers.buildInterfaceFromPropTypeObjectLiteral(propTypesExpressionStatement.expression.right); + } + + return ts.createTypeLiteralNode([]); +} diff --git a/test/end-to-end/basic/output.tsx b/test/end-to-end/basic/output.tsx index 5dae9ae..b2d81de 100644 --- a/test/end-to-end/basic/output.tsx +++ b/test/end-to-end/basic/output.tsx @@ -1,9 +1,7 @@ import * as React from 'react'; -type MyComponentProps = { -}; -type MyComponentState = { -}; -export default class MyComponent extends React.Component { +export default class MyComponent extends React.Component<{ + }, { + }> { render() { return
; } diff --git a/test/end-to-end/stateless-arrow-function/input.tsx b/test/end-to-end/stateless-arrow-function/input.tsx new file mode 100644 index 0000000..9ddb8b7 --- /dev/null +++ b/test/end-to-end/stateless-arrow-function/input.tsx @@ -0,0 +1,7 @@ +const Hello = ({ message }) => { + return
hello {message}
+}; + +Hello.propTypes = { + message: React.PropTypes.string, +} diff --git a/test/end-to-end/stateless-arrow-function/output.tsx b/test/end-to-end/stateless-arrow-function/output.tsx new file mode 100644 index 0000000..818a84d --- /dev/null +++ b/test/end-to-end/stateless-arrow-function/output.tsx @@ -0,0 +1,6 @@ +type HelloProps = { + message?: string; +}; +const Hello: React.SFC = ({ message }) => { + return
hello {message}
; +}; diff --git a/test/end-to-end/stateless-function/input.tsx b/test/end-to-end/stateless-function/input.tsx new file mode 100644 index 0000000..402c686 --- /dev/null +++ b/test/end-to-end/stateless-function/input.tsx @@ -0,0 +1,7 @@ +export function Hello({ message }) { + return
hello {message}
+} + +Hello.propTypes = { + message: React.PropTypes.string, +} diff --git a/test/end-to-end/stateless-function/output.tsx b/test/end-to-end/stateless-function/output.tsx new file mode 100644 index 0000000..fb38d33 --- /dev/null +++ b/test/end-to-end/stateless-function/output.tsx @@ -0,0 +1,6 @@ +type HelloProps = { + message?: string; +}; +export const Hello: React.SFC = ({ message }) => { + return
hello {message}
; +}; diff --git a/test/react-hoist-generics-transform/non-react/input.tsx b/test/react-hoist-generics-transform/non-react/input.tsx deleted file mode 100644 index c0e4a28..0000000 --- a/test/react-hoist-generics-transform/non-react/input.tsx +++ /dev/null @@ -1,18 +0,0 @@ -interface IFoo { - -} -class Foo { - -} - -class Bar extends Foo { - -} - -class Foo2 implements IFoo { - -} - -class Bar2 extends Foo implements IFoo { - -} diff --git a/test/react-hoist-generics-transform/non-react/output.tsx b/test/react-hoist-generics-transform/non-react/output.tsx deleted file mode 100644 index ea73175..0000000 --- a/test/react-hoist-generics-transform/non-react/output.tsx +++ /dev/null @@ -1,10 +0,0 @@ -interface IFoo { -} -class Foo { -} -class Bar extends Foo { -} -class Foo2 implements IFoo { -} -class Bar2 extends Foo implements IFoo { -} diff --git a/test/react-hoist-generics-transform/propless-stateless/input.tsx b/test/react-hoist-generics-transform/propless-stateless/input.tsx deleted file mode 100644 index ab0d702..0000000 --- a/test/react-hoist-generics-transform/propless-stateless/input.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from 'react'; - -export default class MyComponent extends React.Component<{}, {}> { - render() { - return
; - } -} diff --git a/test/react-hoist-generics-transform/propless-stateless/output.tsx b/test/react-hoist-generics-transform/propless-stateless/output.tsx deleted file mode 100644 index 5dae9ae..0000000 --- a/test/react-hoist-generics-transform/propless-stateless/output.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from 'react'; -type MyComponentProps = { -}; -type MyComponentState = { -}; -export default class MyComponent extends React.Component { - render() { - return
; - } -} diff --git a/test/react-hoist-generics-transform/props-and-state/input.tsx b/test/react-hoist-generics-transform/props-and-state/input.tsx deleted file mode 100644 index c1e13db..0000000 --- a/test/react-hoist-generics-transform/props-and-state/input.tsx +++ /dev/null @@ -1,7 +0,0 @@ -import * as React from 'react'; - -export default class MyComponent extends React.Component<{foo: string; bar: object;}, {baz: string; [k: string]: string}> { - render() { - return
; - } -} diff --git a/test/react-hoist-generics-transform/props-and-state/output.tsx b/test/react-hoist-generics-transform/props-and-state/output.tsx deleted file mode 100644 index a2321a1..0000000 --- a/test/react-hoist-generics-transform/props-and-state/output.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import * as React from 'react'; -type MyComponentProps = { - foo: string; - bar: object; -}; -type MyComponentState = { - baz: string; - [k: string]: string; -}; -export default class MyComponent extends React.Component { - render() { - return
; - } -} diff --git a/test/react-js-make-props-and-state-transform/set-state-advanced/output.tsx b/test/react-js-make-props-and-state-transform/set-state-advanced/output.tsx index ec13c77..15ae2c3 100644 --- a/test/react-js-make-props-and-state-transform/set-state-advanced/output.tsx +++ b/test/react-js-make-props-and-state-transform/set-state-advanced/output.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; +type MyComponentState = { foo: number; bar: number; } & { baz: number; } & { something: { big: number; here: string; of: { a: number; }[]; }; }; export default class MyComponent extends React.Component<{ - }, { foo: number; bar: number; } & { baz: number; } & { something: { big: number; here: string; of: { a: number; }[]; }; }> { + }, MyComponentState> { render() { return