From 821a9a36998ab04b7ff4e71bba8b2f45fab8849a Mon Sep 17 00:00:00 2001 From: Victor Louro Date: Mon, 12 Oct 2020 15:10:00 -0300 Subject: [PATCH] new: Add JSX Pragma and Preact support --- src/convertBabelToPropTypes.ts | 34 +++++++++------- src/convertTSToPropTypes.ts | 33 +++++++++------- src/index.ts | 27 ++++++++----- src/jsx-pragma.ts | 71 ++++++++++++++++++++++++++++++++++ src/propTypes.ts | 13 ++++++- src/types.ts | 3 ++ tests/index.test.ts | 14 +++++++ 7 files changed, 155 insertions(+), 40 deletions(-) create mode 100644 src/jsx-pragma.ts diff --git a/src/convertBabelToPropTypes.ts b/src/convertBabelToPropTypes.ts index 53214b3..ede48d6 100644 --- a/src/convertBabelToPropTypes.ts +++ b/src/convertBabelToPropTypes.ts @@ -5,6 +5,12 @@ import { addComment } from '@babel/types'; import { convertSymbolFromSource } from './convertTSToPropTypes'; import extractEnumValues from './extractEnumValues'; import getTypeName from './getTypeName'; +import { + getDefaultImportNameFromPragma, + getNodeTypesFromPragma, + getFunctionTypesFromPragma, + getElementTypesFromPragma, +} from './jsx-pragma'; import { createCall, createMember, @@ -22,6 +28,8 @@ function convert(type: any, state: ConvertState, depth: number): PropType | null const { reactImportedName, propTypes } = state; const propTypesImportedName = propTypes.defaultImport; const isMaxDepth = depth >= state.options.maxDepth; + const { jsxPragma } = state.options; + const defaultImportName = getDefaultImportNameFromPragma(jsxPragma); // Remove wrapping parens if (t.isTSParenthesizedType(type)) { @@ -123,19 +131,17 @@ function convert(type: any, state: ConvertState, depth: number): PropType | null // node } else if ( - isReactTypeMatch(name, 'ReactText', reactImportedName) || - isReactTypeMatch(name, 'ReactNode', reactImportedName) || - isReactTypeMatch(name, 'ReactType', reactImportedName) || - isReactTypeMatch(name, 'ElementType', reactImportedName) + getNodeTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return createMember(t.identifier('node'), propTypesImportedName); // function } else if ( - isReactTypeMatch(name, 'ComponentType', reactImportedName) || - isReactTypeMatch(name, 'ComponentClass', reactImportedName) || - isReactTypeMatch(name, 'StatelessComponent', reactImportedName) || - isReactTypeMatch(name, 'ElementType', reactImportedName) + getFunctionTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return getInstalledPropTypesVersion() >= PROP_TYPES_15_7 ? createMember(t.identifier('elementType'), propTypesImportedName) @@ -143,17 +149,15 @@ function convert(type: any, state: ConvertState, depth: number): PropType | null // element } else if ( - isReactTypeMatch(name, 'Element', 'JSX') || - isReactTypeMatch(name, 'ReactElement', reactImportedName) || - isReactTypeMatch(name, 'ComponentElement', reactImportedName) || - isReactTypeMatch(name, 'FunctionComponentElement', reactImportedName) || - isReactTypeMatch(name, 'DOMElement', reactImportedName) || - isReactTypeMatch(name, 'SFCElement', reactImportedName) + isReactTypeMatch(name, 'Element', defaultImportName, 'JSX') || + getElementTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return createMember(t.identifier('element'), propTypesImportedName); // oneOfType - } else if (isReactTypeMatch(name, 'Ref', reactImportedName)) { + } else if (isReactTypeMatch(name, 'Ref', defaultImportName, reactImportedName)) { return createCall( t.identifier('oneOfType'), [ diff --git a/src/convertTSToPropTypes.ts b/src/convertTSToPropTypes.ts index 14fe288..c484b89 100644 --- a/src/convertTSToPropTypes.ts +++ b/src/convertTSToPropTypes.ts @@ -9,12 +9,20 @@ import { isReactTypeMatch, // wrapIsRequired, } from './propTypes'; +import { + getDefaultImportNameFromPragma, + getNodeTypesFromPragma, + getFunctionTypesFromPragma, + getElementTypesFromPragma, +} from './jsx-pragma'; import { ConvertState, PropType } from './types'; export function convert(type: ts.Type, state: ConvertState, depth: number): PropType | null { const { reactImportedName, propTypes } = state; const propTypesImportedName = propTypes.defaultImport; const isMaxDepth = depth >= (state.options.maxDepth || 3); + const jsxPragma = state.options.jsxPragma; + const defaultImportName = getDefaultImportNameFromPragma(jsxPragma); // Remove wrapping parens // if (ts.isParenthesizedExpression(type)) { @@ -82,34 +90,31 @@ export function convert(type: ts.Type, state: ConvertState, depth: number): Prop // node if ( - isReactTypeMatch(name, 'ReactText', reactImportedName) || - isReactTypeMatch(name, 'ReactNode', reactImportedName) || - isReactTypeMatch(name, 'ReactType', reactImportedName) + getNodeTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return createMember(t.identifier('node'), propTypesImportedName); // function } else if ( - isReactTypeMatch(name, 'ComponentType', reactImportedName) || - isReactTypeMatch(name, 'ComponentClass', reactImportedName) || - isReactTypeMatch(name, 'StatelessComponent', reactImportedName) || - isReactTypeMatch(name, 'ElementType', reactImportedName) + getFunctionTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return createMember(t.identifier('func'), propTypesImportedName); // element } else if ( - isReactTypeMatch(name, 'Element', 'JSX') || - isReactTypeMatch(name, 'ReactElement', reactImportedName) || - isReactTypeMatch(name, 'ComponentElement', reactImportedName) || - isReactTypeMatch(name, 'FunctionComponentElement', reactImportedName) || - isReactTypeMatch(name, 'DOMElement', reactImportedName) || - isReactTypeMatch(name, 'SFCElement', reactImportedName) + isReactTypeMatch(name, 'Element', defaultImportName, 'JSX') || + getElementTypesFromPragma(jsxPragma).some((element) => + isReactTypeMatch(name, element, defaultImportName, reactImportedName), + ) ) { return createMember(t.identifier('element'), propTypesImportedName); // oneOfType - } else if (isReactTypeMatch(name, 'Ref', reactImportedName)) { + } else if (isReactTypeMatch(name, 'Ref', defaultImportName, reactImportedName)) { return createCall( t.identifier('oneOfType'), [ diff --git a/src/index.ts b/src/index.ts index 183e6fa..91ffd11 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,11 +16,16 @@ import extractTypeProperties from './extractTypeProperties'; // import { loadProgram } from './typeChecker'; import upsertImport from './upsertImport'; import { Path, PluginOptions, ConvertState, PropTypeDeclaration } from './types'; +import { + checkForUnsupportedPragma, + getClassComponentNamesFromPragma, + getDefaultImportNameFromPragma, + getFcNamesFromPragma, +} from './jsx-pragma'; const BABEL_VERSION = 7; const MAX_DEPTH = 3; const MAX_SIZE = 25; -const REACT_FC_NAMES = ['SFC', 'StatelessComponent', 'FC', 'FunctionComponent']; function isNotTS(name: string): boolean { return name.endsWith('.js') || name.endsWith('.jsx'); @@ -75,6 +80,7 @@ export default declare((api: any, options: PluginOptions, root: string) => { comments: false, customPropTypeSuffixes: [], forbidExtraProps: false, + jsxPragma: 'react', maxDepth: MAX_DEPTH, maxSize: MAX_SIZE, strict: true, @@ -89,6 +95,7 @@ export default declare((api: any, options: PluginOptions, root: string) => { reactImportedName: '', referenceTypes: {}, }; + checkForUnsupportedPragma(((this as any).state as ConvertState).options.jsxPragma); }, visitor: { @@ -132,9 +139,9 @@ export default declare((api: any, options: PluginOptions, root: string) => { state.airbnbPropTypes.forbidImport = response.namedImport; } - if (node.source.value === 'react') { + if (node.source.value === state.options.jsxPragma) { const response = upsertImport(node, { - checkForDefault: 'React', + checkForDefault: getDefaultImportNameFromPragma(state.options.jsxPragma), }); state.reactImportedName = response.defaultImport; @@ -190,21 +197,22 @@ export default declare((api: any, options: PluginOptions, root: string) => { // @ts-expect-error 'ClassDeclaration|ClassExpression': (path: Path) => { const { node } = path; + + const classComponentNames = getClassComponentNamesFromPragma(state.options.jsxPragma); + // prettier-ignore const valid = node.superTypeParameters && ( // React.Component, React.PureComponent ( t.isMemberExpression(node.superClass) && t.isIdentifier(node.superClass.object, { name: state.reactImportedName }) && ( - t.isIdentifier(node.superClass.property, { name: 'Component' }) || - t.isIdentifier(node.superClass.property, { name: 'PureComponent' }) + classComponentNames.some(name => t.isIdentifier((node.superClass as t.MemberExpression).property, { name })) ) ) || // Component, PureComponent ( state.reactImportedName && ( - t.isIdentifier(node.superClass, { name: 'Component' }) || - t.isIdentifier(node.superClass, { name: 'PureComponent' }) + classComponentNames.some(name => t.isIdentifier(node.superClass, { name })) ) ) ); @@ -307,6 +315,7 @@ export default declare((api: any, options: PluginOptions, root: string) => { // const Foo: React.FC = () => {}; if (id?.typeAnnotation?.typeAnnotation) { const type = id.typeAnnotation.typeAnnotation; + const fcNames = getFcNamesFromPragma(state.options.jsxPragma); if ( t.isTSTypeReference(type) && @@ -323,14 +332,14 @@ export default declare((api: any, options: PluginOptions, root: string) => { t.isIdentifier(type.typeName.left, { name: state.reactImportedName, }) && - REACT_FC_NAMES.some((name) => + fcNames.some((name) => // @ts-expect-error TODO: revisit once babel types stabilize t.isIdentifier((type.typeName as any).right, { name }), )) || // FC, FunctionComponent (!!state.reactImportedName && // @ts-expect-error TODO: revisit once babel types stabilize - REACT_FC_NAMES.some((name) => t.isIdentifier(type.typeName, { name })))) + fcNames.some((name) => t.isIdentifier(type.typeName, { name })))) ) { // @ts-expect-error TODO: revisit once babel types stabilize props = type.typeParameters.params[0]; diff --git a/src/jsx-pragma.ts b/src/jsx-pragma.ts new file mode 100644 index 0000000..f64ac20 --- /dev/null +++ b/src/jsx-pragma.ts @@ -0,0 +1,71 @@ +import { JsxPragma } from './types'; + +const JSXPragmaToFcNamesMap: Record = { + preact: ['ComponentFactory', 'FunctionComponent', 'FunctionalComponent'], + react: ['SFC', 'StatelessComponent', 'FC', 'FunctionComponent'], +}; + +const JSXPragmaToClassComponentNamesMap: Record = { + preact: ['Component', 'AnyComponent', 'ComponentClass', 'ComponentConstructor'], + react: ['Component', 'PureComponent'], +}; + +const JSXPragmaToFcDefaultImportNameMap: Record = { + preact: 'preact', + react: 'React', +}; + +const JSXPragmaToNodeTypesMap: Record = { + preact: ['VNode', 'Text', 'Element', 'JSX.Element', 'JSX.ElementClass'], + react: ['ReactText', 'ReactNode', 'ReactType', 'ElementType'], +}; + +const JSXPragmaToFunctionTypesMap: Record = { + preact: ['VNode', 'ComponentType', 'ComponentClass', 'ComponentConstructor', 'AnyComponent', 'ComponentType', 'Element', 'JSX.Element', 'JSX.ElementClass'], + react: ['ComponentType', 'ComponentClass', 'StatelessComponent', 'ElementType'], +}; + +const JSXPragmaToElementTypesMap: Record = { + preact: ['VNode', 'Element', 'JSX.Element', 'JSX.ElementClass'], + react: [ + 'ReactElement', + 'ComponentElement', + 'FunctionComponentElement', + 'DOMElement', + 'SFCElement', + ], +}; + +export function checkForUnsupportedPragma(pragma: string) { + if (!JSXPragmaToFcNamesMap[pragma as JsxPragma]) { + throw new Error( + `Unsupported JSX Pragma: ${pragma}, please use one of the following list: [${Object.keys( + JSXPragmaToFcNamesMap, + ).join(', ')}]`, + ); + } +} + +export function getFcNamesFromPragma(pragma: JsxPragma): string[] { + return JSXPragmaToFcNamesMap[pragma]; +} + +export function getClassComponentNamesFromPragma(pragma: JsxPragma): string[] { + return JSXPragmaToClassComponentNamesMap[pragma]; +} + +export function getDefaultImportNameFromPragma(pragma: JsxPragma): string { + return JSXPragmaToFcDefaultImportNameMap[pragma]; +} + +export function getNodeTypesFromPragma(pragma: JsxPragma): string[] { + return JSXPragmaToNodeTypesMap[pragma]; +} + +export function getFunctionTypesFromPragma(pragma: JsxPragma): string[] { + return JSXPragmaToFunctionTypesMap[pragma]; +} + +export function getElementTypesFromPragma(pragma: JsxPragma): string[] { + return JSXPragmaToElementTypesMap[pragma]; +} diff --git a/src/propTypes.ts b/src/propTypes.ts index dd57f1c..83a2b29 100644 --- a/src/propTypes.ts +++ b/src/propTypes.ts @@ -5,8 +5,17 @@ export function hasCustomPropTypeSuffix(name: string, suffixes?: string[]): bool return !!suffixes && suffixes.some((suffix) => name.endsWith(suffix)); } -export function isReactTypeMatch(name: string, type: string, reactImportedName: string): boolean { - return name === type || name === `React.${type}` || name === `${reactImportedName}.${type}`; +export function isReactTypeMatch( + name: string, + type: string, + defaultImportName: string, + reactImportedName: string, +): boolean { + return ( + name === type || + name === `${defaultImportName}.${type}` || + name === `${reactImportedName}.${type}` + ); } export function wrapIsRequired(propType: PropType, optional?: boolean | null): PropType { diff --git a/src/types.ts b/src/types.ts index ed2d21e..2d49fe3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,6 +11,8 @@ export type PropTypeDeclaration = t.TSTypeReference | t.TSIntersectionType | t.T export type PropType = t.MemberExpression | t.CallExpression | t.Identifier | t.Literal; +export type JsxPragma = 'react' | 'preact'; + export interface PluginOptions { comments?: boolean; customPropTypeSuffixes?: string[]; @@ -20,6 +22,7 @@ export interface PluginOptions { maxSize?: number; strict?: boolean; typeCheck?: boolean | string; + jsxPragma?: JsxPragma; } export interface ConvertState { diff --git a/tests/index.test.ts b/tests/index.test.ts index 563ded8..9bea7c4 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -307,6 +307,20 @@ describe('babel-plugin-typescript-to-proptypes', () => { ), ).toMatchSnapshot(); }); + + it('emits unsupported pragma error if jsxPragma is not in the supported list', () => { + expect( + () => transform( + path.join(__dirname, './fixtures/special/ts-preset.ts'), + { + presets: ['@babel/preset-typescript'], + }, + { + jsxPragma: 'something-else' as any, + }, + ), + ).toThrow(/Unsupported JSX Pragma/u); + }); }); // describe('typeCheck', () => {