@sanity/codegen
Version:
Codegen toolkit for Sanity.io
233 lines (203 loc) • 6.78 kB
text/typescript
import {createRequire} from 'node:module'
import {type NodePath, type TransformOptions, traverse} from '@babel/core'
import {type Scope} from '@babel/traverse'
import * as babelTypes from '@babel/types'
import {getBabelConfig} from '../getBabelConfig'
import {type NamedQueryResult, resolveExpression} from './expressionResolvers'
import {parseSourceFile} from './parseSource'
const require = createRequire(__filename)
const groqTagName = 'groq'
const defineQueryFunctionName = 'defineQuery'
const groqModuleName = 'groq'
const nextSanityModuleName = 'next-sanity'
const ignoreValue = '@sanity-typegen-ignore'
/**
* findQueriesInSource takes a source string and returns all GROQ queries in it.
* @param source - The source code to search for queries
* @param filename - The filename of the source code
* @param babelConfig - The babel configuration to use when parsing the source
* @param resolver - A resolver function to use when resolving module imports
* @returns
* @beta
* @internal
*/
export function findQueriesInSource(
source: string,
filename: string,
babelConfig: TransformOptions = getBabelConfig(),
resolver: NodeJS.RequireResolve = require.resolve,
): NamedQueryResult[] {
const queries: NamedQueryResult[] = []
const file = parseSourceFile(source, filename, babelConfig)
traverse(file, {
// Look for variable declarations, e.g. `const myQuery = groq`... and extract the query.
// The variable name is used as the name of the query result type
VariableDeclarator(path) {
const {node, scope} = path
const init = node.init
// Look for tagged template expressions that are called with the `groq` tag
const isGroqTemplateTag =
babelTypes.isTaggedTemplateExpression(init) &&
babelTypes.isIdentifier(init.tag) &&
init.tag.name === groqTagName
// Look for strings wrapped in a defineQuery function call
const isDefineQueryCall =
babelTypes.isCallExpression(init) &&
(isImportFrom(groqModuleName, defineQueryFunctionName, scope, init.callee) ||
isImportFrom(nextSanityModuleName, defineQueryFunctionName, scope, init.callee))
if (babelTypes.isIdentifier(node.id) && (isGroqTemplateTag || isDefineQueryCall)) {
// If we find a comment leading the decleration which macthes with ignoreValue we don't add
// the query
if (declarationLeadingCommentContains(path, ignoreValue)) {
return
}
const queryName = `${node.id.name}`
const queryResult = resolveExpression({
node: init,
file,
scope,
babelConfig,
filename,
resolver,
})
const location = node.loc
? {
start: {
...node.loc?.start,
},
end: {
...node.loc?.end,
},
}
: {}
queries.push({name: queryName, result: queryResult, location})
}
},
})
return queries
}
function declarationLeadingCommentContains(path: NodePath, comment: string): boolean {
/*
* We have to consider these cases:
*
* // @sanity-typegen-ignore
* const query = groq`...`
*
* // AST
* VariableDeclaration {
* declarations: [
* VariableDeclarator: {init: tag: {name: "groq"}}
* ],
* leadingComments: ...
* }
*
* // @sanity-typegen-ignore
* const query1 = groq`...`, query2 = groq`...`
*
* // AST
* VariableDeclaration {
* declarations: [
* VariableDeclarator: {init: tag: {name: "groq"}}
* VariableDeclarator: {init: tag: {name: "groq"}}
* ],
* leadingComments: ...
* }
*
* // @sanity-typegen-ignore
* export const query = groq`...`
*
* // AST
* ExportNamedDeclaration {
* declaration: VariableDeclaration {
* declarations: [
* VariableDeclarator: {init: tag: {name: "groq"}}
* VariableDeclarator: {init: tag: {name: "groq"}}
* ],
* },
* leadingComments: ...
* }
*
* In the case where multiple variables are under the same VariableDeclaration the leadingComments
* will still be on the VariableDeclaration
*
* In the case where the variable is exported, the leadingComments are on the
* ExportNamedDeclaration which includes the VariableDeclaration in its own declaration property
*/
const variableDeclaration = path.find((node) => node.isVariableDeclaration())
if (!variableDeclaration) return false
if (
variableDeclaration.node.leadingComments?.find(
(commentItem) => commentItem.value.trim() === comment,
)
) {
return true
}
// If the declaration is exported, the comment lies on the parent of the export declaration
if (
variableDeclaration.parent.leadingComments?.find(
(commentItem) => commentItem.value.trim() === comment,
)
) {
return true
}
return false
}
function isImportFrom(
moduleName: string,
importName: string,
scope: Scope,
node: babelTypes.Expression | babelTypes.V8IntrinsicIdentifier,
) {
if (babelTypes.isIdentifier(node)) {
const binding = scope.getBinding(node.name)
if (!binding) {
return false
}
const {path} = binding
// import { foo } from 'groq'
if (babelTypes.isImportSpecifier(path.node)) {
return (
path.node.importKind === 'value' &&
path.parentPath &&
babelTypes.isImportDeclaration(path.parentPath.node) &&
path.parentPath.node.source.value === moduleName &&
babelTypes.isIdentifier(path.node.imported) &&
path.node.imported.name === importName
)
}
// const { defineQuery } = require('groq')
if (babelTypes.isVariableDeclarator(path.node)) {
const {init} = path.node
return (
babelTypes.isCallExpression(init) &&
babelTypes.isIdentifier(init.callee) &&
init.callee.name === 'require' &&
babelTypes.isStringLiteral(init.arguments[0]) &&
init.arguments[0].value === moduleName
)
}
}
// import * as foo from 'groq'
// foo.defineQuery(...)
if (babelTypes.isMemberExpression(node)) {
const {object, property} = node
if (!babelTypes.isIdentifier(object)) {
return false
}
const binding = scope.getBinding(object.name)
if (!binding) {
return false
}
const {path} = binding
return (
babelTypes.isIdentifier(object) &&
babelTypes.isIdentifier(property) &&
property.name === importName &&
babelTypes.isImportNamespaceSpecifier(path.node) &&
path.parentPath &&
babelTypes.isImportDeclaration(path.parentPath.node) &&
path.parentPath.node.source.value === moduleName
)
}
return false
}