diff --git a/codemods/react/create-element-to-jsx/.codemodrc.json b/codemods/react/create-element-to-jsx/.codemodrc.json new file mode 100644 index 00000000..3aaea8cd --- /dev/null +++ b/codemods/react/create-element-to-jsx/.codemodrc.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://codemod-utils.s3.us-west-1.amazonaws.com/configuration_schema.json", + "name": "react/create-element-to-jsx", + "description": "Transform React.createElement to JSX", + "version": "1.0.0", + "engine": "jscodeshift", + "private": false, + "arguments": [], + "meta": { + "tags": ["react", "migration"], + "git": "https://github.com/reactjs/react-codemod/tree/master/transforms" + } +} diff --git a/codemods/react/create-element-to-jsx/.gitignore b/codemods/react/create-element-to-jsx/.gitignore new file mode 100644 index 00000000..76add878 --- /dev/null +++ b/codemods/react/create-element-to-jsx/.gitignore @@ -0,0 +1,2 @@ +node_modules +dist \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/LICENSE b/codemods/react/create-element-to-jsx/LICENSE new file mode 100644 index 00000000..9e051010 --- /dev/null +++ b/codemods/react/create-element-to-jsx/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2015-present, Facebook, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/codemods/react/create-element-to-jsx/README.md b/codemods/react/create-element-to-jsx/README.md new file mode 100644 index 00000000..26c25b03 --- /dev/null +++ b/codemods/react/create-element-to-jsx/README.md @@ -0,0 +1,25 @@ +This codemod transforms React.createElement calls into JSX syntax, making your code more readable and maintainable. + +## Example + +### Before + +```tsx +return React.createElement( + "div", + { className: "container" }, + React.createElement("h1", null, "Hello World"), + React.createElement("p", { style: { color: "blue" } }, "Welcome to React") +); +``` + +### After + +```tsx +return ( +
+

Hello World

+

Welcome to React

+
+); +``` diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.input.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.input.jsx new file mode 100644 index 00000000..bc07fd30 --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.input.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +function App() { + return React.createElement('div', null, 'Hello World'); +} + +export default App; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.output.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.output.jsx new file mode 100644 index 00000000..91003aa1 --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture1.output.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function App() { + return ( +
+ Hello World +
+ ); +} + +export default App; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.input.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.input.jsx new file mode 100644 index 00000000..6f3caba1 --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.input.jsx @@ -0,0 +1,15 @@ +import React from 'react'; + +function Button({ disabled }) { + return React.createElement( + 'button', + { + className: 'btn', + onClick: () => console.log('clicked'), + disabled + }, + 'Click me' + ); +} + +export default Button; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.output.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.output.jsx new file mode 100644 index 00000000..00b93033 --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture2.output.jsx @@ -0,0 +1,14 @@ +import React from 'react'; + +function Button({ disabled }) { + return ( + + ); +} + +export default Button; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.input.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.input.jsx new file mode 100644 index 00000000..5e17c747 --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.input.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +const Button = ({ onClick, children }) => React.createElement( + 'button', + { onClick }, + children +); + +function App() { + return React.createElement( + 'div', + { className: 'container', id: 'main' }, + React.createElement( + 'h1', + { style: { color: 'blue' } }, + 'Hello World' + ), + React.createElement( + Button, + { onClick: () => console.log('clicked') }, + 'Click me' + ) + ); +} + +export default App; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.output.jsx b/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.output.jsx new file mode 100644 index 00000000..c2f9290f --- /dev/null +++ b/codemods/react/create-element-to-jsx/__testfixtures__/fixture3.output.jsx @@ -0,0 +1,20 @@ +import React from 'react'; + +const Button = ({ onClick, children }) => ; + +function App() { + return ( +
+

+ Hello World +

+ +
+ ); +} + +export default App; \ No newline at end of file diff --git a/codemods/react/create-element-to-jsx/cdmd_dist/index.cjs b/codemods/react/create-element-to-jsx/cdmd_dist/index.cjs new file mode 100644 index 00000000..13ee3baf --- /dev/null +++ b/codemods/react/create-element-to-jsx/cdmd_dist/index.cjs @@ -0,0 +1,251 @@ +module.exports = function transform(file, api, options) { + const j = api.jscodeshift; + const printOptions = options.printOptions || {}; + const root = j(file.source); + const ReactUtils = require("@react-codemods/utils")(j); + const encodeJSXTextValue = (value) => + value.replace(//g, ">"); + + const canLiteralBePropString = (node) => + node.raw.indexOf("\\") === -1 && node.value.indexOf('"') === -1; + + const convertExpressionToJSXAttributes = (expression) => { + if (!expression) { + return { + attributes: [], + extraComments: [], + }; + } + + const isReactSpread = + expression.type === "CallExpression" && + expression.callee.type === "MemberExpression" && + expression.callee.object.name === "React" && + expression.callee.property.name === "__spread"; + + const isObjectAssign = + expression.type === "CallExpression" && + expression.callee.type === "MemberExpression" && + expression.callee.object.name === "Object" && + expression.callee.property.name === "assign"; + + const validSpreadTypes = [ + "Identifier", + "MemberExpression", + "CallExpression", + ]; + + if (isReactSpread || isObjectAssign) { + const resultAttributes = []; + const resultExtraComments = expression.comments || []; + const { callee } = expression; + for (const node of [callee, callee.object, callee.property]) { + resultExtraComments.push(...(node.comments || [])); + } + expression.arguments.forEach((expression) => { + const { attributes, extraComments } = + convertExpressionToJSXAttributes(expression); + resultAttributes.push(...attributes); + resultExtraComments.push(...extraComments); + }); + + return { + attributes: resultAttributes, + extraComments: resultExtraComments, + }; + } else if (validSpreadTypes.indexOf(expression.type) != -1) { + return { + attributes: [j.jsxSpreadAttribute(expression)], + extraComments: [], + }; + } else if (expression.type === "ObjectExpression") { + const attributes = expression.properties.map((property) => { + if (property.type === "SpreadProperty") { + const spreadAttribute = j.jsxSpreadAttribute(property.argument); + spreadAttribute.comments = property.comments; + return spreadAttribute; + } else if (property.type === "Property") { + const propertyValueType = property.value.type; + + let value; + if ( + propertyValueType === "Literal" && + typeof property.value.value === "string" && + canLiteralBePropString(property.value) + ) { + value = j.literal(property.value.value); + value.comments = property.value.comments; + } else { + value = j.jsxExpressionContainer(property.value); + } + + let jsxIdentifier; + if (property.key.type === "Literal") { + jsxIdentifier = j.jsxIdentifier(property.key.value); + } else { + jsxIdentifier = j.jsxIdentifier(property.key.name); + } + jsxIdentifier.comments = property.key.comments; + + const jsxAttribute = j.jsxAttribute(jsxIdentifier, value); + jsxAttribute.comments = property.comments; + return jsxAttribute; + } + return null; + }); + + return { + attributes, + extraComments: expression.comments || [], + }; + } else if (expression.type === "Literal" && expression.value === null) { + return { + attributes: [], + extraComments: expression.comments || [], + }; + } else { + throw new Error(`Unexpected attribute of type "${expression.type}"`); + } + }; + + const canConvertToJSXIdentifier = (node) => + (node.type === "Literal" && typeof node.value === "string") || + node.type === "Identifier" || + (node.type === "MemberExpression" && + !node.computed && + canConvertToJSXIdentifier(node.object) && + canConvertToJSXIdentifier(node.property)); + + const jsxIdentifierFor = (node) => { + let identifier; + let comments = node.comments || []; + if (node.type === "Literal") { + identifier = j.jsxIdentifier(node.value); + } else if (node.type === "MemberExpression") { + let { identifier: objectIdentifier, comments: objectComments } = + jsxIdentifierFor(node.object); + let { identifier: propertyIdentifier, comments: propertyComments } = + jsxIdentifierFor(node.property); + identifier = j.jsxMemberExpression(objectIdentifier, propertyIdentifier); + comments.push(...objectComments, ...propertyComments); + } else { + identifier = j.jsxIdentifier(node.name); + } + return { identifier, comments }; + }; + + const isCapitalizationInvalid = (node) => + (node.type === "Literal" && !/^[a-z]/.test(node.value)) || + (node.type === "Identifier" && /^[a-z]/.test(node.name)); + + const convertNodeToJSX = (node) => { + const comments = node.value.comments || []; + const { callee } = node.value; + for (const calleeNode of [callee, callee.object, callee.property]) { + for (const comment of calleeNode.comments || []) { + comment.leading = true; + comment.trailing = false; + comments.push(comment); + } + } + + const args = node.value.arguments; + + if ( + isCapitalizationInvalid(args[0]) || + !canConvertToJSXIdentifier(args[0]) + ) { + return node.value; + } + + const { identifier: jsxIdentifier, comments: identifierComments } = + jsxIdentifierFor(args[0]); + const props = args[1]; + + const { attributes, extraComments } = + convertExpressionToJSXAttributes(props); + + for (const comment of [...identifierComments, ...extraComments]) { + comment.leading = false; + comment.trailing = true; + comments.push(comment); + } + + const children = args.slice(2).map((child, index) => { + if ( + child.type === "Literal" && + typeof child.value === "string" && + !child.comments && + child.value !== "" && + child.value.trim() === child.value + ) { + return j.jsxText(encodeJSXTextValue(child.value)); + } else if ( + child.type === "CallExpression" && + child.callee.object && + child.callee.object.name === "React" && + child.callee.property.name === "createElement" + ) { + const jsxChild = convertNodeToJSX(node.get("arguments", index + 2)); + if ( + jsxChild.type !== "JSXElement" || + (jsxChild.comments || []).length > 0 + ) { + return j.jsxExpressionContainer(jsxChild); + } else { + return jsxChild; + } + } else if (child.type === "SpreadElement") { + return j.jsxExpressionContainer(child.argument); + } else { + return j.jsxExpressionContainer(child); + } + }); + + const openingElement = j.jsxOpeningElement(jsxIdentifier, attributes); + + if (children.length) { + const endIdentifier = Object.assign({}, jsxIdentifier, { comments: [] }); + // Add text newline nodes between elements so recast formats one child per + // line instead of all children on one line. + const paddedChildren = [j.jsxText("\n")]; + for (const child of children) { + paddedChildren.push(child, j.jsxText("\n")); + } + const element = j.jsxElement( + openingElement, + j.jsxClosingElement(endIdentifier), + paddedChildren, + ); + element.comments = comments; + return element; + } else { + openingElement.selfClosing = true; + const element = j.jsxElement(openingElement); + element.comments = comments; + return element; + } + }; + + if (options["explicit-require"] === false || ReactUtils.hasReact(root)) { + const mutations = root + .find(j.CallExpression, { + callee: { + object: { + name: "React", + }, + property: { + name: "createElement", + }, + }, + }) + .replaceWith(convertNodeToJSX) + .size(); + + if (mutations) { + return root.toSource(printOptions); + } + } + + return null; +}; diff --git a/codemods/react/create-element-to-jsx/package.json b/codemods/react/create-element-to-jsx/package.json new file mode 100644 index 00000000..ba26015f --- /dev/null +++ b/codemods/react/create-element-to-jsx/package.json @@ -0,0 +1,27 @@ +{ + "name": "create-element-to-jsx", + "license": "MIT", + "devDependencies": { + "@codemod.com/codemod-utils": "*", + "@types/jscodeshift": "^0.11.10", + "@types/node": "20.9.0", + "jscodeshift": "^0.15.1", + "typescript": "^5.2.2", + "vitest": "^1.0.1", + "@react-codemods/utils": "workspace:*" + }, + "scripts": { + "test": "vitest run", + "test:watch": "vitest watch" + }, + "files": [ + "README.md", + ".codemodrc.json", + "/dist/index.cjs" + ], + "type": "module", + "author": "amirabbas-gh", + "dependencies": { + "ast-types": "^0.14.2" + } +} diff --git a/codemods/react/create-element-to-jsx/src/index.cjs b/codemods/react/create-element-to-jsx/src/index.cjs new file mode 100644 index 00000000..13ee3baf --- /dev/null +++ b/codemods/react/create-element-to-jsx/src/index.cjs @@ -0,0 +1,251 @@ +module.exports = function transform(file, api, options) { + const j = api.jscodeshift; + const printOptions = options.printOptions || {}; + const root = j(file.source); + const ReactUtils = require("@react-codemods/utils")(j); + const encodeJSXTextValue = (value) => + value.replace(//g, ">"); + + const canLiteralBePropString = (node) => + node.raw.indexOf("\\") === -1 && node.value.indexOf('"') === -1; + + const convertExpressionToJSXAttributes = (expression) => { + if (!expression) { + return { + attributes: [], + extraComments: [], + }; + } + + const isReactSpread = + expression.type === "CallExpression" && + expression.callee.type === "MemberExpression" && + expression.callee.object.name === "React" && + expression.callee.property.name === "__spread"; + + const isObjectAssign = + expression.type === "CallExpression" && + expression.callee.type === "MemberExpression" && + expression.callee.object.name === "Object" && + expression.callee.property.name === "assign"; + + const validSpreadTypes = [ + "Identifier", + "MemberExpression", + "CallExpression", + ]; + + if (isReactSpread || isObjectAssign) { + const resultAttributes = []; + const resultExtraComments = expression.comments || []; + const { callee } = expression; + for (const node of [callee, callee.object, callee.property]) { + resultExtraComments.push(...(node.comments || [])); + } + expression.arguments.forEach((expression) => { + const { attributes, extraComments } = + convertExpressionToJSXAttributes(expression); + resultAttributes.push(...attributes); + resultExtraComments.push(...extraComments); + }); + + return { + attributes: resultAttributes, + extraComments: resultExtraComments, + }; + } else if (validSpreadTypes.indexOf(expression.type) != -1) { + return { + attributes: [j.jsxSpreadAttribute(expression)], + extraComments: [], + }; + } else if (expression.type === "ObjectExpression") { + const attributes = expression.properties.map((property) => { + if (property.type === "SpreadProperty") { + const spreadAttribute = j.jsxSpreadAttribute(property.argument); + spreadAttribute.comments = property.comments; + return spreadAttribute; + } else if (property.type === "Property") { + const propertyValueType = property.value.type; + + let value; + if ( + propertyValueType === "Literal" && + typeof property.value.value === "string" && + canLiteralBePropString(property.value) + ) { + value = j.literal(property.value.value); + value.comments = property.value.comments; + } else { + value = j.jsxExpressionContainer(property.value); + } + + let jsxIdentifier; + if (property.key.type === "Literal") { + jsxIdentifier = j.jsxIdentifier(property.key.value); + } else { + jsxIdentifier = j.jsxIdentifier(property.key.name); + } + jsxIdentifier.comments = property.key.comments; + + const jsxAttribute = j.jsxAttribute(jsxIdentifier, value); + jsxAttribute.comments = property.comments; + return jsxAttribute; + } + return null; + }); + + return { + attributes, + extraComments: expression.comments || [], + }; + } else if (expression.type === "Literal" && expression.value === null) { + return { + attributes: [], + extraComments: expression.comments || [], + }; + } else { + throw new Error(`Unexpected attribute of type "${expression.type}"`); + } + }; + + const canConvertToJSXIdentifier = (node) => + (node.type === "Literal" && typeof node.value === "string") || + node.type === "Identifier" || + (node.type === "MemberExpression" && + !node.computed && + canConvertToJSXIdentifier(node.object) && + canConvertToJSXIdentifier(node.property)); + + const jsxIdentifierFor = (node) => { + let identifier; + let comments = node.comments || []; + if (node.type === "Literal") { + identifier = j.jsxIdentifier(node.value); + } else if (node.type === "MemberExpression") { + let { identifier: objectIdentifier, comments: objectComments } = + jsxIdentifierFor(node.object); + let { identifier: propertyIdentifier, comments: propertyComments } = + jsxIdentifierFor(node.property); + identifier = j.jsxMemberExpression(objectIdentifier, propertyIdentifier); + comments.push(...objectComments, ...propertyComments); + } else { + identifier = j.jsxIdentifier(node.name); + } + return { identifier, comments }; + }; + + const isCapitalizationInvalid = (node) => + (node.type === "Literal" && !/^[a-z]/.test(node.value)) || + (node.type === "Identifier" && /^[a-z]/.test(node.name)); + + const convertNodeToJSX = (node) => { + const comments = node.value.comments || []; + const { callee } = node.value; + for (const calleeNode of [callee, callee.object, callee.property]) { + for (const comment of calleeNode.comments || []) { + comment.leading = true; + comment.trailing = false; + comments.push(comment); + } + } + + const args = node.value.arguments; + + if ( + isCapitalizationInvalid(args[0]) || + !canConvertToJSXIdentifier(args[0]) + ) { + return node.value; + } + + const { identifier: jsxIdentifier, comments: identifierComments } = + jsxIdentifierFor(args[0]); + const props = args[1]; + + const { attributes, extraComments } = + convertExpressionToJSXAttributes(props); + + for (const comment of [...identifierComments, ...extraComments]) { + comment.leading = false; + comment.trailing = true; + comments.push(comment); + } + + const children = args.slice(2).map((child, index) => { + if ( + child.type === "Literal" && + typeof child.value === "string" && + !child.comments && + child.value !== "" && + child.value.trim() === child.value + ) { + return j.jsxText(encodeJSXTextValue(child.value)); + } else if ( + child.type === "CallExpression" && + child.callee.object && + child.callee.object.name === "React" && + child.callee.property.name === "createElement" + ) { + const jsxChild = convertNodeToJSX(node.get("arguments", index + 2)); + if ( + jsxChild.type !== "JSXElement" || + (jsxChild.comments || []).length > 0 + ) { + return j.jsxExpressionContainer(jsxChild); + } else { + return jsxChild; + } + } else if (child.type === "SpreadElement") { + return j.jsxExpressionContainer(child.argument); + } else { + return j.jsxExpressionContainer(child); + } + }); + + const openingElement = j.jsxOpeningElement(jsxIdentifier, attributes); + + if (children.length) { + const endIdentifier = Object.assign({}, jsxIdentifier, { comments: [] }); + // Add text newline nodes between elements so recast formats one child per + // line instead of all children on one line. + const paddedChildren = [j.jsxText("\n")]; + for (const child of children) { + paddedChildren.push(child, j.jsxText("\n")); + } + const element = j.jsxElement( + openingElement, + j.jsxClosingElement(endIdentifier), + paddedChildren, + ); + element.comments = comments; + return element; + } else { + openingElement.selfClosing = true; + const element = j.jsxElement(openingElement); + element.comments = comments; + return element; + } + }; + + if (options["explicit-require"] === false || ReactUtils.hasReact(root)) { + const mutations = root + .find(j.CallExpression, { + callee: { + object: { + name: "React", + }, + property: { + name: "createElement", + }, + }, + }) + .replaceWith(convertNodeToJSX) + .size(); + + if (mutations) { + return root.toSource(printOptions); + } + } + + return null; +}; diff --git a/codemods/react/create-element-to-jsx/test/test.ts b/codemods/react/create-element-to-jsx/test/test.ts new file mode 100644 index 00000000..3c4770e6 --- /dev/null +++ b/codemods/react/create-element-to-jsx/test/test.ts @@ -0,0 +1,89 @@ +import { describe, it } from "vitest"; +import jscodeshift, { type API } from "jscodeshift"; +import transform from "../src/index.cjs"; +import assert from "node:assert"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +const buildApi = (parser: string | undefined): API => ({ + j: parser ? jscodeshift.withParser(parser) : jscodeshift, + jscodeshift: parser ? jscodeshift.withParser(parser) : jscodeshift, + stats: () => { + console.error( + "The stats function was called, which is not supported on purpose", + ); + }, + report: () => { + console.error( + "The report function was called, which is not supported on purpose", + ); + }, +}); + +describe("create-element-to-jsx", () => { + it("test #1", async () => { + const INPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture1.input.jsx"), + "utf-8", + ); + const OUTPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture1.output.jsx"), + "utf-8", + ); + + const actualOutput = transform( + { + path: "index.jsx", + source: INPUT, + }, + buildApi("jsx"), + {}, + ); + + assert.strictEqual(actualOutput?.trim(), OUTPUT.trim()); + }); + + it("test #2", async () => { + const INPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture2.input.jsx"), + "utf-8", + ); + const OUTPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture2.output.jsx"), + "utf-8", + ); + + const actualOutput = transform( + { + path: "index.jsx", + source: INPUT, + }, + buildApi("jsx"), + {}, + ); + + assert.strictEqual(actualOutput?.trim(), OUTPUT.trim()); + }); + + it("handles spread props", async () => { + const INPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture3.input.jsx"), + "utf-8", + ); + const OUTPUT = await readFile( + join(__dirname, "..", "__testfixtures__/fixture3.output.jsx"), + "utf-8", + ); + + const actualOutput = transform( + { + path: "index.jsx", + source: INPUT, + }, + buildApi("jsx"), + {}, + ); + + assert.strictEqual(actualOutput?.trim(), OUTPUT.trim()); + }); +}); diff --git a/codemods/react/create-element-to-jsx/tsconfig.json b/codemods/react/create-element-to-jsx/tsconfig.json new file mode 100644 index 00000000..03c15498 --- /dev/null +++ b/codemods/react/create-element-to-jsx/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "target": "ESNext", + "moduleResolution": "NodeNext", + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "isolatedModules": true, + "jsx": "react-jsx", + "useDefineForClassFields": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "preserveWatchOutput": true, + "strict": true, + "strictNullChecks": true, + "incremental": true, + "noUncheckedIndexedAccess": true, + "noPropertyAccessFromIndexSignature": false, + "allowJs": true + }, + "include": [ + "./src/**/*.ts", + "./src/**/*.js", + "./test/**/*.ts", + "./test/**/*.js", + "src/index.cjs" + ], + "exclude": ["node_modules", "./dist/**/*"], + "ts-node": { + "transpileOnly": true + } +} diff --git a/codemods/react/create-element-to-jsx/vitest.config.ts b/codemods/react/create-element-to-jsx/vitest.config.ts new file mode 100644 index 00000000..772ad4c3 --- /dev/null +++ b/codemods/react/create-element-to-jsx/vitest.config.ts @@ -0,0 +1,7 @@ +import { configDefaults, defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: [...configDefaults.include, "**/test/*.ts"], + }, +}); diff --git a/tooling/react-utils/index.js b/tooling/react-utils/index.js new file mode 100644 index 00000000..3bc680c5 --- /dev/null +++ b/tooling/react-utils/index.js @@ -0,0 +1,308 @@ +/** + * Copyright 2015-present, Facebook, Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +'use strict'; + +module.exports = function(j) { + const REACT_CREATE_CLASS_MEMBER_EXPRESSION = { + type: 'MemberExpression', + object: { + name: 'React', + }, + property: { + name: 'createClass', + }, + }; + + // --------------------------------------------------------------------------- + // Checks if the file requires a certain module + const hasModule = (path, module) => + path + .findVariableDeclarators() + .filter(j.filters.VariableDeclarator.requiresModule(module)) + .size() === 1 || + path + .find(j.ImportDeclaration, { + type: 'ImportDeclaration', + source: { + type: 'Literal', + }, + }) + .filter(declarator => declarator.value.source.value === module) + .size() === 1; + + const hasReact = path => ( + hasModule(path, 'React') || + hasModule(path, 'react') || + hasModule(path, 'react/addons') || + hasModule(path, 'react-native') + ); + + // --------------------------------------------------------------------------- + // Finds all variable declarations that call React.createClass + const findReactCreateClassCallExpression = path => + j(path).find(j.CallExpression, { + callee: REACT_CREATE_CLASS_MEMBER_EXPRESSION, + }); + + const findReactCreateClass = path => + path + .findVariableDeclarators() + .filter(decl => findReactCreateClassCallExpression(decl).size() > 0); + + const findReactCreateClassExportDefault = path => + path.find(j.ExportDeclaration, { + default: true, + declaration: { + type: 'CallExpression', + callee: REACT_CREATE_CLASS_MEMBER_EXPRESSION, + }, + }); + + const findReactCreateClassModuleExports = path => + path + .find(j.AssignmentExpression, { + left: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'module', + }, + property: { + type: 'Identifier', + name: 'exports', + }, + }, + right: { + type: 'CallExpression', + callee: REACT_CREATE_CLASS_MEMBER_EXPRESSION, + }, + }); + + const getReactCreateClassSpec = classPath => { + const {value} = classPath; + const args = (value.init || value.right || value.declaration).arguments; + if (args && args.length) { + const spec = args[0]; + if (spec.type === 'ObjectExpression' && Array.isArray(spec.properties)) { + return spec; + } + } + return null; + }; + + // --------------------------------------------------------------------------- + // Finds alias for React.Component if used as named import. + const findReactComponentNameByParent = (path, parentClassName) => { + const reactImportDeclaration = path + .find(j.ImportDeclaration, { + type: 'ImportDeclaration', + source: { + type: 'Literal', + }, + }) + .filter(importDeclaration => hasReact(path)); + + const componentImportSpecifier = reactImportDeclaration + .find(j.ImportSpecifier, { + type: 'ImportSpecifier', + imported: { + type: 'Identifier', + name: parentClassName, + }, + }).at(0); + + const paths = componentImportSpecifier.paths(); + return paths.length + ? paths[0].value.local.name + : undefined; + }; + + const removeUnusedSuperClassImport = (path, file, superClassName) => { + if (path.find(j.Identifier, { + type: 'Identifier', + name: superClassName + }).length === 0) { + file.find(j.ImportSpecifier, { + type: 'ImportSpecifier', + imported: { + type: 'Identifier', + name: superClassName, + } + }).remove(); + } + }; + + const findReactES6ClassDeclarationByParent = (path, parentClassName) => { + const componentImport = findReactComponentNameByParent(path, parentClassName); + + const selector = componentImport + ? { + superClass: { + type: 'Identifier', + name: componentImport, + }, + } + : { + superClass: { + type: 'MemberExpression', + object: { + type: 'Identifier', + name: 'React', + }, + property: { + type: 'Identifier', + name: parentClassName, + }, + }, + }; + + return path + .find(j.ClassDeclaration, selector); + }; + + // Finds all classes that extend React.Component + const findReactES6ClassDeclaration = path => { + let classDeclarations = findReactES6ClassDeclarationByParent(path, 'Component'); + if (classDeclarations.size() === 0) { + classDeclarations = findReactES6ClassDeclarationByParent(path, 'PureComponent'); + } + return classDeclarations; + }; + + // --------------------------------------------------------------------------- + // Checks if the React class has mixins + const isMixinProperty = property => { + const key = property.key; + const value = property.value; + return ( + key.name === 'mixins' && + value.type === 'ArrayExpression' && + Array.isArray(value.elements) && + value.elements.length + ); + }; + + const hasMixins = classPath => { + const spec = getReactCreateClassSpec(classPath); + return spec && spec.properties.some(isMixinProperty); + }; + + // --------------------------------------------------------------------------- + // Others + const getClassExtendReactSpec = classPath => classPath.value.body; + + const createCreateReactClassCallExpression = properties => + j.callExpression( + j.memberExpression( + j.identifier('React'), + j.identifier('createClass'), + false + ), + [j.objectExpression(properties)] + ); + + const getComponentName = + classPath => classPath.node.id && classPath.node.id.name; + + // --------------------------------------------------------------------------- + // Direct methods! (see explanation below) + const findAllReactCreateClassCalls = path => + path.find(j.CallExpression, { + callee: REACT_CREATE_CLASS_MEMBER_EXPRESSION, + }); + + // Mixin Stuff + const containSameElements = (ls1, ls2) => { + if (ls1.length !== ls2.length) { + return false; + } + + return ( + ls1.reduce((res, x) => res && ls2.indexOf(x) !== -1, true) && + ls2.reduce((res, x) => res && ls1.indexOf(x) !== -1, true) + ); + }; + + const keyNameIsMixins = property => property.key.name === 'mixins'; + + const isSpecificMixinsProperty = (property, mixinIdentifierNames) => { + const key = property.key; + const value = property.value; + + return ( + key.name === 'mixins' && + value.type === 'ArrayExpression' && + Array.isArray(value.elements) && + value.elements.every(elem => elem.type === 'Identifier') && + containSameElements(value.elements.map(elem => elem.name), mixinIdentifierNames) + ); + }; + + // These following methods assume that the argument is + // a `React.createClass` call expression. In other words, + // they should only be used with `findAllReactCreateClassCalls`. + const directlyGetCreateClassSpec = classPath => { + if (!classPath || !classPath.value) { + return null; + } + const args = classPath.value.arguments; + if (args && args.length) { + const spec = args[0]; + if (spec.type === 'ObjectExpression' && Array.isArray(spec.properties)) { + return spec; + } + } + return null; + }; + + const directlyGetComponentName = classPath => { + let result = ''; + if ( + classPath.parentPath.value && + classPath.parentPath.value.type === 'VariableDeclarator' + ) { + result = classPath.parentPath.value.id.name; + } + return result; + }; + + const directlyHasMixinsField = classPath => { + const spec = directlyGetCreateClassSpec(classPath); + return spec && spec.properties.some(keyNameIsMixins); + }; + + const directlyHasSpecificMixins = (classPath, mixinIdentifierNames) => { + const spec = directlyGetCreateClassSpec(classPath); + return spec && spec.properties.some(prop => isSpecificMixinsProperty(prop, mixinIdentifierNames)); + }; + + return { + createCreateReactClassCallExpression, + findReactES6ClassDeclaration, + findReactCreateClass, + findReactCreateClassCallExpression, + findReactCreateClassModuleExports, + findReactCreateClassExportDefault, + getComponentName, + getReactCreateClassSpec, + getClassExtendReactSpec, + hasMixins, + hasModule, + hasReact, + isMixinProperty, + removeUnusedSuperClassImport, + + // "direct" methods + findAllReactCreateClassCalls, + directlyGetComponentName, + directlyGetCreateClassSpec, + directlyHasMixinsField, + directlyHasSpecificMixins, + }; +}; \ No newline at end of file diff --git a/tooling/react-utils/package.json b/tooling/react-utils/package.json new file mode 100644 index 00000000..8a1f6a27 --- /dev/null +++ b/tooling/react-utils/package.json @@ -0,0 +1,8 @@ +{ + "name": "@react-codemods/utils", + "version": "1.0.0", + "private": true, + "files": [ + "*.js" + ] +}