import * as ts from 'typescript'; import { TypeScriptProject } from './createTypeScriptProject'; export interface ParsedProp { /** * If `true`, some signatures do not contain this property. * For example: `id` in `{ id: number, value: string } | { value: string }` */ onlyUsedInSomeSignatures: boolean; signatures: { symbol: ts.Symbol; componentType: ts.Type }[]; } export interface ParsedComponent { name: string; location: ts.Node; type: ts.Type; sourceFile: ts.SourceFile | undefined; props: Record; } function isTypeJSXElementLike(type: ts.Type, project: TypeScriptProject): boolean { const symbol = type.symbol ?? type.aliasSymbol; if (symbol) { const name = project.checker.getFullyQualifiedName(symbol); return ( // Remove once global JSX namespace is no longer used by React name === 'global.JSX.Element' || name === 'React.JSX.Element' || name === 'React.ReactElement' || name === 'React.ReactNode' ); } if (type.isUnion()) { return type.types.every( // eslint-disable-next-line no-bitwise (subType) => subType.flags & ts.TypeFlags.Null || isTypeJSXElementLike(subType, project), ); } return false; } function isStyledFunction(node: ts.VariableDeclaration): boolean { return ( !!node.initializer && ts.isCallExpression(node.initializer) && ts.isCallExpression(node.initializer.expression) && ts.isIdentifier(node.initializer.expression.expression) && node.initializer.expression.expression.escapedText === 'styled' ); } // TODO update to reflect https://github.com/DefinitelyTyped/DefinitelyTyped/pull/65135 function getJSXLikeReturnValueFromFunction(type: ts.Type, project: TypeScriptProject) { return type .getCallSignatures() .filter((signature) => isTypeJSXElementLike(signature.getReturnType(), project)); } function parsePropsType({ name, type, shouldInclude = () => true, location, sourceFile, }: { name: string; type: ts.Type; location: ts.Node; shouldInclude?: (data: { name: string; depth: number }) => boolean; sourceFile: ts.SourceFile | undefined; }): ParsedComponent { const parsedProps: Record = {}; type .getProperties() .filter((property) => shouldInclude({ name: property.getName(), depth: 1 })) .forEach((property) => { parsedProps[property.getName()] = { signatures: [ { symbol: property, componentType: type, }, ], onlyUsedInSomeSignatures: false, }; }); return { name, location, type, sourceFile, props: parsedProps, }; } function parseFunctionComponent({ node, shouldInclude, project, }: { node: ts.VariableDeclaration | ts.FunctionDeclaration; shouldInclude?: (data: { name: string; depth: number }) => boolean; project: TypeScriptProject; }): ParsedComponent | null { if (!node.name) { return null; } const symbol = project.checker.getSymbolAtLocation(node.name); if (!symbol) { return null; } const componentName = node.name.getText(); // Discriminate render functions to components if (componentName[0].toUpperCase() !== componentName[0]) { return null; } const signatures = getJSXLikeReturnValueFromFunction( project.checker.getTypeOfSymbolAtLocation(symbol, symbol.valueDeclaration!), project, ); if (signatures.length === 0) { return null; } const parsedComponents = signatures.map((signature) => parsePropsType({ shouldInclude, name: componentName, type: project.checker.getTypeOfSymbolAtLocation( signature.parameters[0], signature.parameters[0].valueDeclaration!, ), location: signature.parameters[0].valueDeclaration!, sourceFile: node.getSourceFile(), }), ); const squashedProps: Record = {}; parsedComponents.forEach((parsedComponent) => { Object.keys(parsedComponent.props).forEach((propName) => { if (!squashedProps[propName]) { squashedProps[propName] = parsedComponent.props[propName]; } else { squashedProps[propName].signatures = [ ...squashedProps[propName].signatures, ...parsedComponent.props[propName].signatures, ]; } }); }); const squashedParsedComponent: ParsedComponent = { ...parsedComponents[0], props: squashedProps, }; Object.keys(squashedParsedComponent.props).forEach((propName) => { squashedParsedComponent.props[propName].onlyUsedInSomeSignatures = squashedParsedComponent.props[propName].signatures.length < signatures.length; }); return squashedParsedComponent; } export interface GetPropsFromComponentDeclarationOptions { project: TypeScriptProject; node: ts.Node; /** * Called before a PropType is added to a component/object * @returns true to include the prop, false to skip it */ shouldInclude?: (data: { name: string; depth: number }) => boolean; /** * Control if const declarations should be checked * @default false * @example declare const Component: React.JSXElementConstructor; */ checkDeclarations?: boolean; } function getPropsFromVariableDeclaration({ node, project, checkDeclarations, shouldInclude, }: { node: ts.VariableDeclaration } & Pick< GetPropsFromComponentDeclarationOptions, 'project' | 'checkDeclarations' | 'shouldInclude' >) { const type = project.checker.getTypeAtLocation(node.name); if (!node.initializer) { if ( checkDeclarations && type.aliasSymbol && type.aliasTypeArguments && project.checker.getFullyQualifiedName(type.aliasSymbol) === 'React.JSXElementConstructor' ) { const propsType = type.aliasTypeArguments[0]; if (propsType === undefined) { throw new TypeError( 'Unable to find symbol for `props`. This is a bug in typescript-to-proptypes.', ); } return parsePropsType({ name: node.name.getText(), type: propsType, location: node.name, shouldInclude, sourceFile: node.getSourceFile(), }); } if (checkDeclarations) { return parseFunctionComponent({ node, shouldInclude, project, }); } return null; } if ( (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) && node.initializer.parameters.length === 1 ) { return parseFunctionComponent({ node, shouldInclude, project, }); } // x = React.memo((props:type) { return
}) // x = React.forwardRef((props:type) { return
}) if (ts.isCallExpression(node.initializer) && node.initializer.arguments.length > 0) { const potentialComponent = node.initializer.arguments[0]; if ( (ts.isArrowFunction(potentialComponent) || ts.isFunctionExpression(potentialComponent)) && potentialComponent.parameters.length > 0 && getJSXLikeReturnValueFromFunction( project.checker.getTypeAtLocation(potentialComponent), project, ).length > 0 ) { const propsSymbol = project.checker.getSymbolAtLocation( potentialComponent.parameters[0].name, ); if (propsSymbol) { return parsePropsType({ name: node.name.getText(), type: project.checker.getTypeOfSymbolAtLocation( propsSymbol, propsSymbol.valueDeclaration!, ), location: propsSymbol.valueDeclaration!, shouldInclude, sourceFile: node.getSourceFile(), }); } } } // handle component factories: x = createComponent() if ( checkDeclarations && node.initializer && !isStyledFunction(node) && getJSXLikeReturnValueFromFunction(type, project).length > 0 ) { return parseFunctionComponent({ node, shouldInclude, project, }); } return null; } export function getPropsFromComponentNode({ node, shouldInclude, project, checkDeclarations, }: GetPropsFromComponentDeclarationOptions): ParsedComponent | null { let parsedComponent: ParsedComponent | null = null; // function x(props: type) { return
} if ( ts.isFunctionDeclaration(node) && node.name && node.parameters.length === 1 && getJSXLikeReturnValueFromFunction(project.checker.getTypeAtLocation(node.name), project) .length > 0 ) { parsedComponent = parseFunctionComponent({ node, shouldInclude, project }); } else if (ts.isVariableDeclaration(node)) { parsedComponent = getPropsFromVariableDeclaration({ node, project, checkDeclarations, shouldInclude, }); } else if (ts.isVariableStatement(node)) { // const x = ... ts.forEachChild(node.declarationList, (variableNode) => { if (parsedComponent != null) { return; } // x = (props: type) => { return
} // x = function(props: type) { return
} // x = function y(props: type) { return
} // x = react.memo((props:type) { return
}) if (ts.isVariableDeclaration(variableNode) && variableNode.name) { parsedComponent = getPropsFromVariableDeclaration({ node: variableNode, project, checkDeclarations, shouldInclude, }); } if ( ts.isClassDeclaration(variableNode) && variableNode.name && variableNode.heritageClauses && variableNode.heritageClauses.length === 1 ) { const heritage = variableNode.heritageClauses[0]; if (heritage.types.length !== 1) { return; } const arg = heritage.types[0]; if (!arg.typeArguments) { return; } parsedComponent = parsePropsType({ shouldInclude, name: variableNode.name.getText(), location: arg.typeArguments[0], type: project.checker.getTypeAtLocation(arg.typeArguments[0]), sourceFile: node.getSourceFile(), }); } }); } return parsedComponent; }