diff --git a/packages/codemods/package.json b/packages/codemods/package.json index d78e3416c12f..75dd707a8c04 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -24,8 +24,10 @@ "dependencies": { "@babel/cli": "7.21.0", "@babel/core": "7.21.0", + "@babel/parser": "7.21.2", "@babel/plugin-transform-typescript": "7.21.0", "@babel/runtime-corejs3": "7.21.0", + "@babel/traverse": "7.21.2", "@iarna/toml": "2.2.5", "@vscode/ripgrep": "1.14.2", "@whatwg-node/fetch": "0.8.1", @@ -34,6 +36,7 @@ "execa": "5.1.1", "fast-glob": "3.2.12", "findup-sync": "5.0.0", + "graphql": "16.6.0", "jest": "29.4.2", "jscodeshift": "0.14.0", "prettier": "2.8.4", diff --git a/packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.ts b/packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.ts index b22ea79e2268..63adab2b0b3c 100644 --- a/packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.ts +++ b/packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.ts @@ -1,21 +1,9 @@ -import fs from 'fs' -import path from 'path' - -import { types } from '@babel/core' -import { parse as babelParse, ParserPlugin } from '@babel/parser' -import traverse from '@babel/traverse' -import fg from 'fast-glob' import { - DocumentNode, - FieldNode, - InlineFragmentNode, - OperationDefinitionNode, - OperationTypeNode, - parse, - visit, -} from 'graphql' - -import getRWPaths from '../../../lib/getRWPaths' + findCells, + fileToAst, + getCellGqlQuery, + parseGqlQueryToAst, +} from '../../../lib/cells' async function detectEmptyCells() { const cellPaths = findCells() @@ -48,246 +36,3 @@ async function detectEmptyCells() { } export default detectEmptyCells - -// Copied from @redwoodjs/internal -// ------------------------------------------------ - -export const findCells = (cwd: string = getRWPaths().web.src) => { - const modules = fg.sync('**/*Cell.{js,jsx,ts,tsx}', { - cwd, - absolute: true, - ignore: ['node_modules'], - }) - return modules.filter(isCellFile) -} - -export const isCellFile = (p: string) => { - const { dir, name } = path.parse(p) - - // If the path isn't on the web side it cannot be a cell - if (!isFileInsideFolder(p, getRWPaths().web.src)) { - return false - } - - // A Cell must be a directory named module. - if (!dir.endsWith(name)) { - return false - } - - const ast = fileToAst(p) - - // A Cell should not have a default export. - if (hasDefaultExport(ast)) { - return false - } - - // A Cell must export QUERY and Success. - const exports = getNamedExports(ast) - const exportedQUERY = exports.findIndex((v) => v.name === 'QUERY') !== -1 - const exportedSuccess = exports.findIndex((v) => v.name === 'Success') !== -1 - if (!exportedQUERY && !exportedSuccess) { - return false - } - - return true -} - -export const isFileInsideFolder = (filePath: string, folderPath: string) => { - const { dir } = path.parse(filePath) - const relativePathFromFolder = path.relative(folderPath, dir) - if ( - !relativePathFromFolder || - relativePathFromFolder.startsWith('..') || - path.isAbsolute(relativePathFromFolder) - ) { - return false - } else { - return true - } -} - -export const hasDefaultExport = (ast: types.Node): boolean => { - let exported = false - traverse(ast, { - ExportDefaultDeclaration() { - exported = true - return - }, - }) - return exported -} - -interface NamedExports { - name: string - type: 're-export' | 'variable' | 'function' | 'class' -} - -export const getNamedExports = (ast: types.Node): NamedExports[] => { - const namedExports: NamedExports[] = [] - traverse(ast, { - ExportNamedDeclaration(path) { - // Re-exports from other modules - // Eg: export { a, b } from './module' - const specifiers = path.node?.specifiers - if (specifiers.length) { - for (const s of specifiers) { - const id = s.exported as types.Identifier - namedExports.push({ - name: id.name, - type: 're-export', - }) - } - return - } - - const declaration = path.node.declaration - if (!declaration) { - return - } - - if (declaration.type === 'VariableDeclaration') { - const id = declaration.declarations[0].id as types.Identifier - namedExports.push({ - name: id.name as string, - type: 'variable', - }) - } else if (declaration.type === 'FunctionDeclaration') { - namedExports.push({ - name: declaration?.id?.name as string, - type: 'function', - }) - } else if (declaration.type === 'ClassDeclaration') { - namedExports.push({ - name: declaration?.id?.name, - type: 'class', - }) - } - }, - }) - - return namedExports -} - -export const fileToAst = (filePath: string): types.Node => { - const code = fs.readFileSync(filePath, 'utf-8') - - // use jsx plugin for web files, because in JS, the .jsx extension is not used - const isJsxFile = - path.extname(filePath).match(/[jt]sx$/) || - isFileInsideFolder(filePath, getRWPaths().web.base) - - const plugins = [ - 'typescript', - 'nullishCoalescingOperator', - 'objectRestSpread', - isJsxFile && 'jsx', - ].filter(Boolean) as ParserPlugin[] - - try { - return babelParse(code, { - sourceType: 'module', - plugins, - }) - } catch (e: any) { - // console.error(chalk.red(`Error parsing: ${filePath}`)) - console.error(e) - throw new Error(e?.message) // we throw, so typescript doesn't complain about returning - } -} - -export const getCellGqlQuery = (ast: types.Node) => { - let cellQuery: string | undefined = undefined - traverse(ast, { - ExportNamedDeclaration({ node }) { - if ( - node.exportKind === 'value' && - types.isVariableDeclaration(node.declaration) - ) { - const exportedQueryNode = node.declaration.declarations.find((d) => { - return ( - types.isIdentifier(d.id) && - d.id.name === 'QUERY' && - types.isTaggedTemplateExpression(d.init) - ) - }) - - if (exportedQueryNode) { - const templateExpression = - exportedQueryNode.init as types.TaggedTemplateExpression - - cellQuery = templateExpression.quasi.quasis[0].value.raw - } - } - return - }, - }) - - return cellQuery -} - -export const parseGqlQueryToAst = (gqlQuery: string) => { - const ast = parse(gqlQuery) - return parseDocumentAST(ast) -} - -export const parseDocumentAST = (document: DocumentNode) => { - const operations: Array = [] - - visit(document, { - OperationDefinition(node: OperationDefinitionNode) { - const fields: any[] = [] - - node.selectionSet.selections.forEach((field) => { - fields.push(getFields(field as FieldNode)) - }) - - operations.push({ - operation: node.operation, - name: node.name?.value, - fields, - }) - }, - }) - - return operations -} - -interface Operation { - operation: OperationTypeNode - name: string | undefined - fields: Array -} - -interface Field { - string: Array -} - -const getFields = (field: FieldNode): any => { - // base - if (!field.selectionSet) { - return field.name.value - } else { - const obj: Record = { - [field.name.value]: [], - } - - const lookAtFieldNode = (node: FieldNode | InlineFragmentNode): void => { - node.selectionSet?.selections.forEach((subField) => { - switch (subField.kind) { - case 'Field': - obj[field.name.value].push(getFields(subField as FieldNode)) - break - case 'FragmentSpread': - // TODO: Maybe this will also be needed, right now it's accounted for to not crash in the tests - break - case 'InlineFragment': - lookAtFieldNode(subField) - } - }) - } - - lookAtFieldNode(field) - - return obj - } -} diff --git a/packages/codemods/src/lib/cells.ts b/packages/codemods/src/lib/cells.ts new file mode 100644 index 000000000000..a1da8652bbc7 --- /dev/null +++ b/packages/codemods/src/lib/cells.ts @@ -0,0 +1,258 @@ +import fs from 'fs' +import path from 'path' + +import { types } from '@babel/core' +import { parse as babelParse, ParserPlugin } from '@babel/parser' +import traverse from '@babel/traverse' +import fg from 'fast-glob' +import { + DocumentNode, + FieldNode, + InlineFragmentNode, + OperationDefinitionNode, + OperationTypeNode, + parse, + visit, +} from 'graphql' + +import getRWPaths from './getRWPaths' + +export const findCells = (cwd: string = getRWPaths().web.src) => { + const modules = fg.sync('**/*Cell.{js,jsx,ts,tsx}', { + cwd, + absolute: true, + ignore: ['node_modules'], + }) + return modules.filter(isCellFile) +} + +export const isCellFile = (p: string) => { + const { dir, name } = path.parse(p) + + // If the path isn't on the web side it cannot be a cell + if (!isFileInsideFolder(p, getRWPaths().web.src)) { + return false + } + + // A Cell must be a directory named module. + if (!dir.endsWith(name)) { + return false + } + + const ast = fileToAst(p) + + // A Cell should not have a default export. + if (hasDefaultExport(ast)) { + return false + } + + // A Cell must export QUERY and Success. + const exports = getNamedExports(ast) + const exportedQUERY = exports.findIndex((v) => v.name === 'QUERY') !== -1 + const exportedSuccess = exports.findIndex((v) => v.name === 'Success') !== -1 + if (!exportedQUERY && !exportedSuccess) { + return false + } + + return true +} + +export const isFileInsideFolder = (filePath: string, folderPath: string) => { + const { dir } = path.parse(filePath) + const relativePathFromFolder = path.relative(folderPath, dir) + if ( + !relativePathFromFolder || + relativePathFromFolder.startsWith('..') || + path.isAbsolute(relativePathFromFolder) + ) { + return false + } else { + return true + } +} + +export const hasDefaultExport = (ast: types.Node): boolean => { + let exported = false + traverse(ast, { + ExportDefaultDeclaration() { + exported = true + return + }, + }) + return exported +} + +interface NamedExports { + name: string + type: 're-export' | 'variable' | 'function' | 'class' +} + +export const getNamedExports = (ast: types.Node): NamedExports[] => { + const namedExports: NamedExports[] = [] + traverse(ast, { + ExportNamedDeclaration(path) { + // Re-exports from other modules + // Eg: export { a, b } from './module' + const specifiers = path.node?.specifiers + if (specifiers.length) { + for (const s of specifiers) { + const id = s.exported as types.Identifier + namedExports.push({ + name: id.name, + type: 're-export', + }) + } + return + } + + const declaration = path.node.declaration + if (!declaration) { + return + } + + if (declaration.type === 'VariableDeclaration') { + const id = declaration.declarations[0].id as types.Identifier + namedExports.push({ + name: id.name as string, + type: 'variable', + }) + } else if (declaration.type === 'FunctionDeclaration') { + namedExports.push({ + name: declaration?.id?.name as string, + type: 'function', + }) + } else if (declaration.type === 'ClassDeclaration') { + namedExports.push({ + name: declaration?.id?.name, + type: 'class', + }) + } + }, + }) + + return namedExports +} + +export const fileToAst = (filePath: string): types.Node => { + const code = fs.readFileSync(filePath, 'utf-8') + + // use jsx plugin for web files, because in JS, the .jsx extension is not used + const isJsxFile = + path.extname(filePath).match(/[jt]sx$/) || + isFileInsideFolder(filePath, getRWPaths().web.base) + + const plugins = [ + 'typescript', + 'nullishCoalescingOperator', + 'objectRestSpread', + isJsxFile && 'jsx', + ].filter(Boolean) as ParserPlugin[] + + try { + return babelParse(code, { + sourceType: 'module', + plugins, + }) + } catch (e: any) { + // console.error(chalk.red(`Error parsing: ${filePath}`)) + console.error(e) + throw new Error(e?.message) // we throw, so typescript doesn't complain about returning + } +} + +export const getCellGqlQuery = (ast: types.Node) => { + let cellQuery: string | undefined = undefined + traverse(ast, { + ExportNamedDeclaration({ node }) { + if ( + node.exportKind === 'value' && + types.isVariableDeclaration(node.declaration) + ) { + const exportedQueryNode = node.declaration.declarations.find((d) => { + return ( + types.isIdentifier(d.id) && + d.id.name === 'QUERY' && + types.isTaggedTemplateExpression(d.init) + ) + }) + + if (exportedQueryNode) { + const templateExpression = + exportedQueryNode.init as types.TaggedTemplateExpression + + cellQuery = templateExpression.quasi.quasis[0].value.raw + } + } + return + }, + }) + + return cellQuery +} + +export const parseGqlQueryToAst = (gqlQuery: string) => { + const ast = parse(gqlQuery) + return parseDocumentAST(ast) +} + +export const parseDocumentAST = (document: DocumentNode) => { + const operations: Array = [] + + visit(document, { + OperationDefinition(node: OperationDefinitionNode) { + const fields: any[] = [] + + node.selectionSet.selections.forEach((field) => { + fields.push(getFields(field as FieldNode)) + }) + + operations.push({ + operation: node.operation, + name: node.name?.value, + fields, + }) + }, + }) + + return operations +} + +interface Operation { + operation: OperationTypeNode + name: string | undefined + fields: Array +} + +interface Field { + string: Array +} + +const getFields = (field: FieldNode): any => { + // base + if (!field.selectionSet) { + return field.name.value + } else { + const obj: Record = { + [field.name.value]: [], + } + + const lookAtFieldNode = (node: FieldNode | InlineFragmentNode): void => { + node.selectionSet?.selections.forEach((subField) => { + switch (subField.kind) { + case 'Field': + obj[field.name.value].push(getFields(subField as FieldNode)) + break + case 'FragmentSpread': + // TODO: Maybe this will also be needed, right now it's accounted for to not crash in the tests + break + case 'InlineFragment': + lookAtFieldNode(subField) + } + }) + } + + lookAtFieldNode(field) + + return obj + } +} diff --git a/yarn.lock b/yarn.lock index e132f4859124..e4856d108895 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6893,8 +6893,10 @@ __metadata: dependencies: "@babel/cli": 7.21.0 "@babel/core": 7.21.0 + "@babel/parser": 7.21.2 "@babel/plugin-transform-typescript": 7.21.0 "@babel/runtime-corejs3": 7.21.0 + "@babel/traverse": 7.21.2 "@iarna/toml": 2.2.5 "@types/babel__core": 7.20.0 "@types/findup-sync": 4.0.2 @@ -6911,6 +6913,7 @@ __metadata: fast-glob: 3.2.12 findup-sync: 5.0.0 fs-extra: 11.1.0 + graphql: 16.6.0 jest: 29.4.2 jscodeshift: 0.14.0 prettier: 2.8.4