-
Notifications
You must be signed in to change notification settings - Fork 1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
2 changed files
with
309 additions
and
0 deletions.
There are no files selected for viewing
293 changes: 293 additions & 0 deletions
293
packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,293 @@ | ||
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' | ||
|
||
async function detectEmptyCells() { | ||
const cellPaths = findCells() | ||
|
||
const susceptibleCells = cellPaths.filter((cellPath) => { | ||
const fileContents = fileToAst(cellPath) | ||
const cellQuery = getCellGqlQuery(fileContents) | ||
|
||
if (!cellQuery) { | ||
return false | ||
} | ||
|
||
const { fields } = parseGqlQueryToAst(cellQuery)[0] | ||
|
||
return fields.length > 1 | ||
}) | ||
|
||
if (susceptibleCells.length > 0) { | ||
console.log( | ||
[ | ||
'You have Cells that are susceptible to the new isDataEmpty behavior:', | ||
'', | ||
susceptibleCells.map((c) => `• ${c}`).join('\n'), | ||
'', | ||
'The new behavior is documented in detail here. Consider whether it affects you.', | ||
"If it does, and you'd like to revert to the old behavior, you can override the `isDataEmpty` function.", | ||
].join('\n') | ||
) | ||
} | ||
} | ||
|
||
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<Operation> = [] | ||
|
||
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<string | Field> | ||
} | ||
|
||
interface Field { | ||
string: Array<string | Field> | ||
} | ||
|
||
const getFields = (field: FieldNode): any => { | ||
// base | ||
if (!field.selectionSet) { | ||
return field.name.value | ||
} else { | ||
const obj: Record<string, FieldNode[]> = { | ||
[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 | ||
} | ||
} |
16 changes: 16 additions & 0 deletions
16
packages/codemods/src/codemods/v5.x.x/detectEmptyCells/detectEmptyCells.yargs.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import task, { TaskInnerAPI } from 'tasuku' | ||
|
||
import detectEmptyCells from './detectEmptyCells' | ||
|
||
export const command = 'detect-empty-cells' | ||
export const description = '(v4.x.x->v5.0.0) Detects empty cells and warns' | ||
|
||
export const handler = () => { | ||
task('detectEmptyCells', async ({ setError }: TaskInnerAPI) => { | ||
try { | ||
await detectEmptyCells() | ||
} catch (e: any) { | ||
setError('Failed to codemod your project \n' + e?.message) | ||
} | ||
}) | ||
} |