Skip to content

Commit 3742b3f

Browse files
authoredMar 19, 2024
feat(codegen): add CLI to generate types given a codegen config (#5982)
1 parent baf7cf0 commit 3742b3f

File tree

14 files changed

+409
-7
lines changed

14 files changed

+409
-7
lines changed
 

‎packages/@sanity/codegen/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,10 @@
6868
"@babel/types": "^7.23.9",
6969
"debug": "^4.3.4",
7070
"globby": "^10.0.0",
71+
"json5": "^2.2.3",
7172
"groq-js": "1.5.0-canary.1",
72-
"tsconfig-paths": "^4.2.0"
73+
"tsconfig-paths": "^4.2.0",
74+
"zod": "^3.22.4"
7375
},
7476
"devDependencies": {
7577
"@jest/globals": "^29.7.0",

‎packages/@sanity/codegen/src/_exports/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export {type CodegenConfig, readConfig} from '../readConfig'
12
export {readSchema} from '../readSchema'
23
export {findQueriesInPath} from '../typescript/findQueriesInPath'
34
export {findQueriesInSource} from '../typescript/findQueriesInSource'
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import {readFile} from 'fs/promises'
2+
import * as json5 from 'json5'
3+
import * as z from 'zod'
4+
5+
export const configDefintion = z.object({
6+
path: z.string().or(z.array(z.string())).default('./src/**/*.{ts,tsx,js,jsx}'),
7+
schema: z.string().default('./schema.json'),
8+
generates: z.string().default('./sanity.types.ts'),
9+
})
10+
11+
export type CodegenConfig = z.infer<typeof configDefintion>
12+
13+
export async function readConfig(path: string): Promise<CodegenConfig> {
14+
try {
15+
const content = await readFile(path, 'utf-8')
16+
const json = json5.parse(content)
17+
return configDefintion.parseAsync(json)
18+
} catch (error) {
19+
if (error instanceof z.ZodError) {
20+
throw new Error(`Error in config file\n ${error.errors.map((err) => err.message).join('\n')}`)
21+
}
22+
if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT') {
23+
return configDefintion.parse({})
24+
}
25+
26+
throw error
27+
}
28+
}

‎packages/@sanity/codegen/src/typescript/findQueriesInPath.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type ResultQueries = {
2525
type ResultError = {
2626
type: 'error'
2727
error: Error
28+
filename: string
2829
}
2930

3031
/**
@@ -75,7 +76,7 @@ export async function* findQueriesInPath({
7576
yield {type: 'queries', filename, queries}
7677
} catch (error) {
7778
debug(`Error in file "${filename}"`, error)
78-
yield {type: 'error', error}
79+
yield {type: 'error', error, filename}
7980
}
8081
}
8182
}

‎packages/sanity/package.config.ts

+5
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ export default defineConfig({
4444
require: './lib/_internal/cli/threads/extractSchema.js',
4545
default: './lib/_internal/cli/threads/extractSchema.js',
4646
},
47+
'./_internal/cli/threads/codegenGenerateTypes': {
48+
source: './src/_internal/cli/threads/codegenGenerateTypes.ts',
49+
require: './lib/_internal/cli/threads/codegenGenerateTypes.js',
50+
default: './lib/_internal/cli/threads/codegenGenerateTypes.js',
51+
},
4752
}),
4853

4954
extract: {

‎packages/sanity/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,7 @@
202202
"@sanity/block-tools": "3.34.0",
203203
"@sanity/cli": "3.34.0",
204204
"@sanity/client": "^6.15.6",
205+
"@sanity/codegen": "workspace:*",
205206
"@sanity/color": "^3.0.0",
206207
"@sanity/diff": "3.34.0",
207208
"@sanity/diff-match-patch": "^3.1.1",
@@ -251,7 +252,7 @@
251252
"framer-motion": "^11.0.0",
252253
"get-it": "^8.4.14",
253254
"get-random-values-esm": "1.0.2",
254-
"groq-js": "^1.1.12",
255+
"groq-js": "1.5.0-canary.1",
255256
"hashlru": "^2.3.0",
256257
"history": "^5.3.0",
257258
"i18next": "^23.2.7",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {defineTrace} from '@sanity/telemetry'
2+
3+
interface TypesGeneratedTraceAttrubutes {
4+
outputSize: number
5+
queryTypes: number
6+
schemaTypes: number
7+
files: number
8+
filesWithErrors: number
9+
unknownTypes: number
10+
}
11+
12+
export const TypesGeneratedTrace = defineTrace<TypesGeneratedTraceAttrubutes>({
13+
name: 'Types Generated',
14+
version: 0,
15+
description: 'Trace emitted when generating TypeScript types for queries',
16+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {constants, open} from 'node:fs/promises'
2+
import {dirname, join} from 'node:path'
3+
4+
import {type CliCommandArguments, type CliCommandContext} from '@sanity/cli'
5+
import {readConfig} from '@sanity/codegen'
6+
import readPkgUp from 'read-pkg-up'
7+
import {Worker} from 'worker_threads'
8+
9+
import {
10+
type CodegenGenerateTypesWorkerData,
11+
type CodegenGenerateTypesWorkerMessage,
12+
} from '../../threads/codegenGenerateTypes'
13+
import {TypesGeneratedTrace} from './generateTypes.telemetry'
14+
15+
export interface CodegenGenerateTypesCommandFlags {
16+
configPath?: string
17+
}
18+
19+
export default async function codegenGenerateAction(
20+
args: CliCommandArguments<CodegenGenerateTypesCommandFlags>,
21+
context: CliCommandContext,
22+
): Promise<void> {
23+
const flags = args.extOptions
24+
const {output, workDir, telemetry} = context
25+
26+
const trace = telemetry.trace(TypesGeneratedTrace)
27+
trace.start()
28+
29+
const codegenConfig = await readConfig(flags.configPath || 'sanity-codegen.json')
30+
31+
const rootPkgPath = readPkgUp.sync({cwd: __dirname})?.path
32+
if (!rootPkgPath) {
33+
throw new Error('Could not find root directory for `sanity` package')
34+
}
35+
36+
const workerPath = join(
37+
dirname(rootPkgPath),
38+
'lib',
39+
'_internal',
40+
'cli',
41+
'threads',
42+
'codegenGenerateTypes.js',
43+
)
44+
45+
const spinner = output.spinner({}).start('Generating types')
46+
47+
const worker = new Worker(workerPath, {
48+
workerData: {
49+
workDir,
50+
schemaPath: codegenConfig.schema,
51+
searchPath: codegenConfig.path,
52+
} satisfies CodegenGenerateTypesWorkerData,
53+
// eslint-disable-next-line no-process-env
54+
env: process.env,
55+
})
56+
57+
const typeFile = await open(
58+
join(process.cwd(), codegenConfig.generates),
59+
// eslint-disable-next-line no-bitwise
60+
constants.O_TRUNC | constants.O_CREAT | constants.O_WRONLY,
61+
)
62+
63+
typeFile.write('// This file is generated by `sanity codegen generate`\n')
64+
65+
const stats = {
66+
files: 0,
67+
errors: 0,
68+
queries: 0,
69+
schemas: 0,
70+
unknownTypes: 0,
71+
size: 0,
72+
}
73+
74+
await new Promise<void>((resolve, reject) => {
75+
worker.addListener('message', (msg: CodegenGenerateTypesWorkerMessage) => {
76+
if (msg.type === 'error') {
77+
trace.error(msg.error)
78+
79+
if (msg.fatal) {
80+
reject(msg.error)
81+
return
82+
}
83+
const errorMessage = msg.filename
84+
? `${msg.error.message} in "${msg.filename}"`
85+
: msg.error.message
86+
spinner.fail(errorMessage)
87+
stats.errors++
88+
return
89+
}
90+
if (msg.type === 'complete') {
91+
resolve()
92+
return
93+
}
94+
95+
let fileTypeString = `// ${msg.filename}\n`
96+
97+
if (msg.type === 'schema') {
98+
stats.schemas += msg.length
99+
fileTypeString += `${msg.schema}\n\n`
100+
typeFile.write(fileTypeString)
101+
return
102+
}
103+
104+
stats.files++
105+
for (const {queryName, query, type, unknownTypes} of msg.types) {
106+
fileTypeString += `// ${queryName}\n`
107+
fileTypeString += `// ${query.replace(/(\r\n|\n|\r)/gm, '')}\n`
108+
fileTypeString += `${type}\n`
109+
stats.queries++
110+
stats.unknownTypes += unknownTypes
111+
}
112+
typeFile.write(`${fileTypeString}\n`)
113+
stats.size += Buffer.byteLength(fileTypeString)
114+
})
115+
worker.addListener('error', reject)
116+
})
117+
118+
typeFile.close()
119+
120+
trace.log({
121+
outputSize: stats.size,
122+
queryTypes: stats.queries,
123+
schemaTypes: stats.schemas,
124+
files: stats.files,
125+
filesWithErrors: stats.errors,
126+
unknownTypes: stats.unknownTypes,
127+
})
128+
129+
trace.complete()
130+
if (stats.errors > 0) {
131+
spinner.warn(`Encountered errors in ${stats.errors} files while generating types`)
132+
}
133+
134+
spinner.succeed(
135+
`Generated TypeScript types for ${stats.schemas} schema types and ${stats.queries} queries in ${stats.files} files into: ${codegenConfig.generates}`,
136+
)
137+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import {type CliCommandDefinition} from '@sanity/cli'
2+
3+
const description = 'Generates codegen'
4+
5+
const helpText = `
6+
**Note**: This command is experimental and subject to change.
7+
8+
Options
9+
--help, -h
10+
Show this help text.
11+
12+
Examples
13+
# Generate types from a schema, generate schema with "sanity schema extract" first.
14+
sanity codegen generate-types
15+
16+
Configuration
17+
The codegen command uses the following configuration properties from sanity-codegen.json:
18+
{
19+
"path": "'./src/**/*.{ts,tsx,js,jsx}'" // glob pattern to your typescript files
20+
"schema": "schema.json", // path to your schema file, generated with 'sanity schema extract' command
21+
"generates": "./sanity.types.ts" // path to the file where the types will be generated
22+
}
23+
24+
The listed properties are the default values, and can be overridden in the configuration file.
25+
`
26+
27+
const generateTypesCodegenCommand: CliCommandDefinition = {
28+
name: 'generate-types',
29+
group: 'codegen',
30+
signature: '',
31+
description,
32+
helpText,
33+
hideFromHelp: true,
34+
action: async (args, context) => {
35+
const mod = await import('../../actions/codegen/generateTypesAction')
36+
37+
return mod.default(args, context)
38+
},
39+
} satisfies CliCommandDefinition
40+
41+
export default generateTypesCodegenCommand

‎packages/sanity/src/_internal/cli/commands/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import enableBackupCommand from './backup/enableBackupCommand'
77
import listBackupCommand from './backup/listBackupCommand'
88
import buildCommand from './build/buildCommand'
99
import checkCommand from './check/checkCommand'
10+
import generateTypesCodegenCommand from './codegen/generateTypesCommand'
1011
import configCheckCommand from './config/configCheckCommand'
1112
import addCorsOriginCommand from './cors/addCorsOriginCommand'
1213
import corsGroup from './cors/corsGroup'
@@ -97,6 +98,7 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [
9798
queryDocumentsCommand,
9899
deleteDocumentsCommand,
99100
createDocumentsCommand,
101+
generateTypesCodegenCommand,
100102
validateDocumentsCommand,
101103
graphqlGroup,
102104
listGraphQLAPIsCommand,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import {
2+
findQueriesInPath,
3+
getResolver,
4+
readSchema,
5+
registerBabel,
6+
TypeGenerator,
7+
} from '@sanity/codegen'
8+
import createDebug from 'debug'
9+
import {parse, typeEvaluate, type TypeNode} from 'groq-js'
10+
import {isMainThread, parentPort, workerData as _workerData} from 'worker_threads'
11+
12+
const $info = createDebug('sanity:codegen:generate:info')
13+
14+
export interface CodegenGenerateTypesWorkerData {
15+
workDir: string
16+
workspaceName?: string
17+
schemaPath: string
18+
searchPath: string | string[]
19+
}
20+
21+
export type CodegenGenerateTypesWorkerMessage =
22+
| {
23+
type: 'error'
24+
error: Error
25+
fatal: boolean
26+
query?: string
27+
filename?: string
28+
}
29+
| {
30+
type: 'types'
31+
filename: string
32+
types: {
33+
queryName: string
34+
query: string
35+
type: string
36+
unknownTypes: number
37+
}[]
38+
}
39+
| {
40+
type: 'schema'
41+
filename: string
42+
schema: string
43+
length: number
44+
}
45+
| {
46+
type: 'complete'
47+
}
48+
49+
if (isMainThread || !parentPort) {
50+
throw new Error('This module must be run as a worker thread')
51+
}
52+
53+
const opts = _workerData as CodegenGenerateTypesWorkerData
54+
55+
registerBabel()
56+
57+
async function main() {
58+
const schema = await readSchema(opts.schemaPath)
59+
60+
const typeGenerator = new TypeGenerator(schema)
61+
const schemaTypes = typeGenerator.generateSchemaTypes()
62+
const resolver = getResolver()
63+
64+
parentPort?.postMessage({
65+
type: 'schema',
66+
schema: schemaTypes,
67+
filename: 'schema.json',
68+
length: schema.length,
69+
} satisfies CodegenGenerateTypesWorkerMessage)
70+
71+
const queries = findQueriesInPath({
72+
path: opts.searchPath,
73+
resolver,
74+
})
75+
76+
for await (const result of queries) {
77+
if (result.type === 'error') {
78+
parentPort?.postMessage({
79+
type: 'error',
80+
error: result.error,
81+
fatal: false,
82+
filename: result.filename,
83+
} satisfies CodegenGenerateTypesWorkerMessage)
84+
continue
85+
}
86+
$info(`Processing ${result.queries.length} queries in "${result.filename}"...`)
87+
88+
const fileQueryTypes: {queryName: string; query: string; type: string; unknownTypes: number}[] =
89+
[]
90+
for (const {name: queryName, result: query} of result.queries) {
91+
try {
92+
const ast = parse(query)
93+
const queryTypes = typeEvaluate(ast, schema)
94+
95+
const type = typeGenerator.generateTypeNodeTypes(queryName, queryTypes)
96+
97+
fileQueryTypes.push({
98+
queryName: queryName,
99+
query,
100+
type,
101+
unknownTypes: countUnknownTypes(queryTypes),
102+
})
103+
} catch (err) {
104+
parentPort?.postMessage({
105+
type: 'error',
106+
error: new Error(
107+
`Error generating types for query "${queryName}" in "${result.filename}": ${err.message}`,
108+
{cause: err},
109+
),
110+
fatal: false,
111+
query,
112+
} satisfies CodegenGenerateTypesWorkerMessage)
113+
}
114+
}
115+
116+
if (fileQueryTypes.length > 0) {
117+
$info(`Generated types for ${fileQueryTypes.length} queries in "${result.filename}"\n`)
118+
parentPort?.postMessage({
119+
type: 'types',
120+
types: fileQueryTypes,
121+
filename: result.filename,
122+
} satisfies CodegenGenerateTypesWorkerMessage)
123+
}
124+
}
125+
126+
parentPort?.postMessage({
127+
type: 'complete',
128+
} satisfies CodegenGenerateTypesWorkerMessage)
129+
}
130+
131+
function countUnknownTypes(typeNode: TypeNode): number {
132+
switch (typeNode.type) {
133+
case 'unknown':
134+
return 1
135+
case 'array':
136+
return countUnknownTypes(typeNode.of)
137+
case 'object':
138+
// if the rest is unknown, we count it as one unknown type
139+
if (typeNode.rest && typeNode.rest.type === 'unknown') {
140+
return 1
141+
}
142+
143+
return (
144+
Object.values(typeNode.attributes).reduce(
145+
(acc, attribute) => acc + countUnknownTypes(attribute.value),
146+
0,
147+
) + (typeNode.rest ? countUnknownTypes(typeNode.rest) : 0)
148+
)
149+
case 'union':
150+
return typeNode.of.reduce((acc, type) => acc + countUnknownTypes(type), 0)
151+
152+
default:
153+
return 0
154+
}
155+
}
156+
157+
main()

‎packages/sanity/tsconfig.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
{"path": "../@sanity/types/tsconfig.lib.json"},
4343
{"path": "../@sanity/cli/tsconfig.lib.json"},
4444
{"path": "../@sanity/util/tsconfig.lib.json"},
45-
{"path": "../@sanity/migrate/tsconfig.lib.json"}
45+
{"path": "../@sanity/migrate/tsconfig.lib.json"},
46+
{"path": "../@sanity/codegen/tsconfig.lib.json"}
4647
]
4748
}

‎packages/sanity/tsconfig.lib.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
{"path": "../@sanity/types/tsconfig.lib.json"},
4242
{"path": "../@sanity/cli/tsconfig.lib.json"},
4343
{"path": "../@sanity/util/tsconfig.lib.json"},
44-
{"path": "../@sanity/migrate/tsconfig.lib.json"}
44+
{"path": "../@sanity/migrate/tsconfig.lib.json"},
45+
{"path": "../@sanity/codegen/tsconfig.lib.json"}
4546
]
4647
}

‎pnpm-lock.yaml

+11-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.