Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(codegen): add CLI to generate types given a codegen config #5982

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 3 additions & 1 deletion packages/@sanity/codegen/package.json
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
@@ -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('./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
}
}
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
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 @@ -202,6 +202,7 @@
"@sanity/block-tools": "3.34.0",
"@sanity/cli": "3.34.0",
"@sanity/client": "^6.15.6",
"@sanity/codegen": "workspace:*",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we publish this package.json file so it may be better to match the latest version of this package. I think pnpm/lerna should link/version it automatically. I don't think workspace:* will resolve when publishing this to npm.

Copy link
Member

@bjoerge bjoerge Mar 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think workspace:* will resolve when publishing this to npm.

It will: https://pnpm.io/workspaces#publishing-workspace-packages (it should also be respected by lerna)

Ideally we should use the workspace protocol for all workspace packages.

"@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.14",
"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,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',
})
@@ -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}`,
)
}
@@ -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
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