Skip to content

Commit

Permalink
feat(codegen): add CLI to generate types given a codegen config
Browse files Browse the repository at this point in the history
  • Loading branch information
sgulseth committed Mar 12, 2024
1 parent 40225b3 commit 1d3f323
Show file tree
Hide file tree
Showing 12 changed files with 343 additions and 7 deletions.
4 changes: 3 additions & 1 deletion packages/@sanity/codegen/package.json
Expand Up @@ -66,7 +66,9 @@
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"groq-js": "1.5.0-canary.1",
"rxjs": "^7.8.1"
"json5": "^2.2.3",
"rxjs": "^7.8.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@jest/globals": "^29.7.0",
Expand Down
1 change: 1 addition & 0 deletions packages/@sanity/codegen/src/_exports/index.ts
@@ -1,3 +1,4 @@
export {type CodegenConfig, readConfig} from '../readConfig'
export {readSchema} from '../readSchema'
export {findQueriesInPath} from '../typescript/findQueriesInPath'
export {findQueriesInSource} from '../typescript/findQueriesInSource'
Expand Down
28 changes: 28 additions & 0 deletions packages/@sanity/codegen/src/readConfig.ts
@@ -0,0 +1,28 @@
import {readFile} from 'fs/promises'
import * as json5 from 'json5'
import * as z from 'zod'

export const configDefintion = z.object({
path: z.string().or(z.array(z.string())).default('./src/**/*.{ts,tsx,js,jsx}'),
schema: z.string().default('./schema.json'),
generates: z.string().default('./groq-types.ts'),
})

export type CodegenConfig = z.infer<typeof configDefintion>

export async function readConfig(path: string): Promise<CodegenConfig> {
try {
const content = await readFile(path, 'utf-8')
const json = json5.parse(content)
return configDefintion.parseAsync(json)
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(`Error in config file\n ${error.errors.map((err) => err.message).join('\n')}`)
}
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
return configDefintion.parse({})
}

throw error
}
}
5 changes: 5 additions & 0 deletions packages/sanity/package.config.ts
Expand Up @@ -44,6 +44,11 @@ export default defineConfig({
require: './lib/_internal/cli/threads/extractSchema.js',
default: './lib/_internal/cli/threads/extractSchema.js',
},
'./_internal/cli/threads/codegenGenerateTypes': {
source: './src/_internal/cli/threads/codegenGenerateTypes.ts',
require: './lib/_internal/cli/threads/codegenGenerateTypes.js',
default: './lib/_internal/cli/threads/codegenGenerateTypes.js',
},
}),

extract: {
Expand Down
3 changes: 2 additions & 1 deletion packages/sanity/package.json
Expand Up @@ -203,6 +203,7 @@
"@sanity/cli": "3.32.0",
"@sanity/client": "^6.15.4",
"@sanity/color": "^3.0.0",
"@sanity/codegen": "workspace:*",
"@sanity/diff": "3.32.0",
"@sanity/diff-match-patch": "^3.1.1",
"@sanity/eventsource": "^5.0.0",
Expand Down Expand Up @@ -251,7 +252,7 @@
"framer-motion": "^11.0.0",
"get-it": "^8.4.11",
"get-random-values-esm": "1.0.2",
"groq-js": "^1.1.12",
"groq-js": "1.5.0-canary.1",
"hashlru": "^2.3.0",
"history": "^5.3.0",
"i18next": "^23.2.7",
Expand Down
@@ -0,0 +1,112 @@
import {constants, open} from 'node:fs/promises'
import {dirname, join} from 'node:path'

import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
import {readConfig} from '@sanity/codegen'
import readPkgUp from 'read-pkg-up'
import {Worker} from 'worker_threads'

import {
type CodegenGenerateWorkerData,
type CodegenGenerateWorkerMessage,
} from '../../threads/codegenGenerateTypes'
import {getTimer} from '../../util/timing'

export interface CodegenGenerateTypesCommandFlags {
configPath?: string
}

export default async function codegenGenerateAction(
args: CliCommandArguments<CodegenGenerateTypesCommandFlags>,
context: CliCommandContext,
): Promise<void> {
const timers = getTimer()
const flags = args.extOptions
const {output, workDir} = context

timers.start('codegen.generateTypesAction')

const codegenConfig = await readConfig(flags.configPath || 'sanity-codegen.json')

const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path
if (!rootPkgPath) {
throw new Error('Could not find root directory for `sanity` package')
}

const workerPath = join(
dirname(rootPkgPath),
'lib',
'_internal',
'cli',
'threads',
'codegenGenerateTypes.js',
)

const spinner = output.spinner({prefixText: '✨', text: 'Generating types'}).start()

const worker = new Worker(workerPath, {
workerData: {
workDir,
config: codegenConfig,
} satisfies CodegenGenerateWorkerData,
// eslint-disable-next-line no-process-env
env: process.env,
})

const typeFile = await open(
join(process.cwd(), codegenConfig.generates),
// eslint-disable-next-line no-bitwise
constants.O_TRUNC | constants.O_CREAT | constants.O_WRONLY,
)

typeFile.write('// This file is generated by `sanity codegen generate`\n')

const stats = {
files: 0,
errors: 0,
queries: 0,
}

await new Promise<void>((resolve, reject) => {
worker.addListener('message', (msg: CodegenGenerateWorkerMessage) => {
if ('error' in msg) {
if (msg.fatal) {
reject(msg.error)
} else {
spinner.fail(msg.error.message)
stats.errors++
}
return
}
if ('complete' in msg) {
resolve()
return
}

let fileTypeString = `// ${msg.filename}\n`

if (typeof msg.types === 'string') {
fileTypeString += `${msg.types}\n\n`
typeFile.write(fileTypeString)
return
}

stats.files++
for (const {queryName, query, type} of msg.types) {
fileTypeString += `// ${queryName}\n`
fileTypeString += `// ${query.replace(/(\r\n|\n|\r)/gm, '')}\n`
fileTypeString += `${type}\n`
stats.queries++
}
typeFile.write(`${fileTypeString}\n`)
})
worker.addListener('error', reject)
})

typeFile.close()

timers.end('codegen.generateTypesAction')
spinner.succeed(
`Generated codegen types for ${stats.queries} queries in ${stats.files} files, failed to parse ${stats.errors} files`,
)
}
@@ -0,0 +1,40 @@
import {type CliCommandDefinition} from '@sanity/cli'

const description = 'Generates codegen'

const helpText = `
**Note**: This command is experimental and subject to change.
Options
--help, -h
Show this help text.
Examples
# Generate types from a schema, generate schema with "sanity schema extract" first.
sanity codegen generate-types
Configuration
The codegen command uses the following configuration properties from sanity-codegen.json:
{
"path": "'./src/**/*.{ts,tsx,js,jsx}'" // glob pattern to your typescript files
"schema": "schema.json", // path to your schema file, generated with 'sanity schema extract' command
"generates": "./groq-types.ts" // path to the file where the types will be generated
}
The listed properties are the default values, and can be overridden in the configuration file.
`

const generateTypesCodegenCommand: CliCommandDefinition = {
name: 'generate-types',
group: 'codegen',
signature: '',
description,
helpText,
action: async (args, context) => {
const mod = await import('../../actions/codegen/generateTypesAction')

return mod.default(args, context)
},
} satisfies CliCommandDefinition

export default generateTypesCodegenCommand
2 changes: 2 additions & 0 deletions packages/sanity/src/_internal/cli/commands/index.ts
Expand Up @@ -7,6 +7,7 @@ import enableBackupCommand from './backup/enableBackupCommand'
import listBackupCommand from './backup/listBackupCommand'
import buildCommand from './build/buildCommand'
import checkCommand from './check/checkCommand'
import generateTypesCodegenCommand from './codegen/generateTypesCommand'
import configCheckCommand from './config/configCheckCommand'
import addCorsOriginCommand from './cors/addCorsOriginCommand'
import corsGroup from './cors/corsGroup'
Expand Down Expand Up @@ -97,6 +98,7 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [
queryDocumentsCommand,
deleteDocumentsCommand,
createDocumentsCommand,
generateTypesCodegenCommand,
validateDocumentsCommand,
graphqlGroup,
listGraphQLAPIsCommand,
Expand Down
134 changes: 134 additions & 0 deletions packages/sanity/src/_internal/cli/threads/codegenGenerateTypes.ts
@@ -0,0 +1,134 @@
import {
type CodegenConfig,
findQueriesInPath,
getResolver,
readSchema,
registerBabel,
TypeGenerator,
} from '@sanity/codegen'
import debug from 'debug'
import {parse, typeEvaluate} from 'groq-js'
import {map} from 'rxjs/operators'
import {isMainThread, parentPort, workerData as _workerData} from 'worker_threads'

const $info = debug('sanity:codegen:generate:info')

export interface CodegenGenerateWorkerData {
workDir: string
workspaceName?: string
config: CodegenConfig
}

export type CodegenGenerateWorkerMessage =
| {
error: Error
fatal: boolean
query?: string
}
| {
filename: string
types:
| string
| {
queryName: string
query: string
type: string
}[]
}
| {
complete: true
}

if (isMainThread || !parentPort) {
throw new Error('This module must be run as a worker thread')
}

const opts = _workerData as CodegenGenerateWorkerData

registerBabel()

async function main() {
try {
const schema = await readSchema(opts.config.schema)

const typeGenerator = new TypeGenerator(schema)
const schemaTypes = typeGenerator.generateTypesFromSchema()
const resolver = await getResolver()

parentPort?.postMessage({
types: schemaTypes,
filename: 'schema.json',
} satisfies CodegenGenerateWorkerMessage)

return findQueriesInPath({
path: opts.config.path,
resolver,
})
.pipe(
map((findResult) => {
if (findResult.error) {
parentPort?.postMessage({
error: findResult.error,
fatal: false,
} satisfies CodegenGenerateWorkerMessage)
return {filename: findResult.filename, result: []}
}

const queries = [...findResult.queries.values()]
if (queries.length === 0) {
return {filename: findResult.filename, result: []}
}
$info(`Processing ${queries.length} queries in "${findResult.filename}"...`)

const result: {queryName: string; query: string; type: string}[] = []
for (const {name: queryName, result: query} of queries) {
try {
const ast = parse(query)
const queryTypes = typeEvaluate(ast, schema)

const type = typeGenerator.generateTypeForField(queryName, queryTypes)

result.push({queryName, query, type})
} catch (err) {
parentPort?.postMessage({
error: new Error(
`Error generating types for query "${queryName}" in "${findResult.filename}": ${err.message}`,
{cause: err},
),
fatal: false,
query,
} satisfies CodegenGenerateWorkerMessage)
}
}

return {filename: findResult.filename, result}
}),
)
.subscribe({
next: (result) => {
if (result.result.length > 0) {
$info(`Generated types for ${result.result.length} queries in "${result.filename}"\n`)
parentPort?.postMessage({
types: result.result,
filename: result.filename,
} satisfies CodegenGenerateWorkerMessage)
}
},
error: (err) => {
parentPort?.postMessage({
error: new Error(`Error generating types: ${err.message}`, {cause: err}),
fatal: true,
} satisfies CodegenGenerateWorkerMessage)
},
complete: () => {
parentPort?.postMessage({
complete: true,
} satisfies CodegenGenerateWorkerMessage)
},
})
} finally {
// nothing
}
}

main()
5 changes: 3 additions & 2 deletions packages/sanity/tsconfig.json
Expand Up @@ -42,6 +42,7 @@
{"path": "../@sanity/types/tsconfig.lib.json"},
{"path": "../@sanity/cli/tsconfig.lib.json"},
{"path": "../@sanity/util/tsconfig.lib.json"},
{"path": "../@sanity/migrate/tsconfig.lib.json"}
]
{"path": "../@sanity/migrate/tsconfig.lib.json"},
{"path": "../@sanity/codegen/tsconfig.lib.json"},
],
}
3 changes: 2 additions & 1 deletion packages/sanity/tsconfig.lib.json
Expand Up @@ -41,6 +41,7 @@
{"path": "../@sanity/types/tsconfig.lib.json"},
{"path": "../@sanity/cli/tsconfig.lib.json"},
{"path": "../@sanity/util/tsconfig.lib.json"},
{"path": "../@sanity/migrate/tsconfig.lib.json"}
{"path": "../@sanity/migrate/tsconfig.lib.json"},
{"path": "../groq/tsconfig.lib.json"}
]
}

0 comments on commit 1d3f323

Please sign in to comment.