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 18, 2024
1 parent b648872 commit 0a18f59
Show file tree
Hide file tree
Showing 14 changed files with 409 additions and 7 deletions.
4 changes: 3 additions & 1 deletion packages/@sanity/codegen/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,10 @@
"@babel/types": "^7.23.9",
"debug": "^4.3.4",
"globby": "^10.0.0",
"json5": "^2.2.3",
"groq-js": "1.5.0-canary.1",
"tsconfig-paths": "^4.2.0"
"tsconfig-paths": "^4.2.0",
"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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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('./sanity.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
}
}
3 changes: 2 additions & 1 deletion packages/@sanity/codegen/src/typescript/findQueriesInPath.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ResultQueries = {
type ResultError = {
type: 'error'
error: Error
filename: string
}

/**
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function* findQueriesInPath({
yield {type: 'queries', filename, queries}
} catch (error) {
debug(`Error in file "${filename}"`, error)
yield {type: 'error', error}
yield {type: 'error', error, filename}
}
}
}
5 changes: 5 additions & 0 deletions packages/sanity/package.config.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@
"@sanity/block-tools": "3.34.0",
"@sanity/cli": "3.34.0",
"@sanity/client": "^6.15.5",
"@sanity/codegen": "workspace:*",
"@sanity/color": "^3.0.0",
"@sanity/diff": "3.34.0",
"@sanity/diff-match-patch": "^3.1.1",
Expand Down Expand Up @@ -251,7 +252,7 @@
"framer-motion": "^11.0.0",
"get-it": "^8.4.13",
"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
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {defineTrace} from '@sanity/telemetry'

interface TypesGeneratedTraceAttrubutes {
outputSize: number
queryTypes: number
schemaTypes: number
files: number
filesWithErrors: number
unknownTypes: number
}

export const TypesGeneratedTrace = defineTrace<TypesGeneratedTraceAttrubutes>({
name: 'Types Generated',
version: 0,
description: 'Trace emitted when generating TypeScript types for queries',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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 CodegenGenerateTypesWorkerData,
type CodegenGenerateTypesWorkerMessage,
} from '../../threads/codegenGenerateTypes'
import {TypesGeneratedTrace} from './generateTypes.telemetry'

export interface CodegenGenerateTypesCommandFlags {
configPath?: string
}

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

const trace = telemetry.trace(TypesGeneratedTrace)
trace.start()

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({}).start('Generating types')

const worker = new Worker(workerPath, {
workerData: {
workDir,
schemaPath: codegenConfig.schema,
searchPath: codegenConfig.path,
} satisfies CodegenGenerateTypesWorkerData,
// 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,
schemas: 0,
unknownTypes: 0,
size: 0,
}

await new Promise<void>((resolve, reject) => {
worker.addListener('message', (msg: CodegenGenerateTypesWorkerMessage) => {
if (msg.type === 'error') {
trace.error(msg.error)

if (msg.fatal) {
reject(msg.error)
return
}
const errorMessage = msg.filename
? `${msg.error.message} in "${msg.filename}"`
: msg.error.message
spinner.fail(errorMessage)
stats.errors++
return
}
if (msg.type === 'complete') {
resolve()
return
}

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

if (msg.type === 'schema') {
stats.schemas += msg.length
fileTypeString += `${msg.schema}\n\n`
typeFile.write(fileTypeString)
return
}

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

typeFile.close()

trace.log({
outputSize: stats.size,
queryTypes: stats.queries,
schemaTypes: stats.schemas,
files: stats.files,
filesWithErrors: stats.errors,
unknownTypes: stats.unknownTypes,
})

trace.complete()
if (stats.errors > 0) {
spinner.warn(`Encountered errors in ${stats.errors} files while generating types`)
}

spinner.succeed(
`Generated TypeScript types for ${stats.schemas} schema types and ${stats.queries} queries in ${stats.files} files into: ${codegenConfig.generates}`,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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": "./sanity.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,
hideFromHelp: true,
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit 0a18f59

Please sign in to comment.