diff --git a/packages/@sanity/codegen/package.json b/packages/@sanity/codegen/package.json index f8ebd0dd977..a9033b05f6b 100644 --- a/packages/@sanity/codegen/package.json +++ b/packages/@sanity/codegen/package.json @@ -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", diff --git a/packages/@sanity/codegen/src/_exports/index.ts b/packages/@sanity/codegen/src/_exports/index.ts index 1e12f33b8e6..83cae78520d 100644 --- a/packages/@sanity/codegen/src/_exports/index.ts +++ b/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' diff --git a/packages/@sanity/codegen/src/readConfig.ts b/packages/@sanity/codegen/src/readConfig.ts new file mode 100644 index 00000000000..a38ce1d4c00 --- /dev/null +++ b/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 + +export async function readConfig(path: string): Promise { + 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 + } +} diff --git a/packages/@sanity/codegen/src/typescript/findQueriesInPath.ts b/packages/@sanity/codegen/src/typescript/findQueriesInPath.ts index dbcf534f1ff..5c544d0c778 100644 --- a/packages/@sanity/codegen/src/typescript/findQueriesInPath.ts +++ b/packages/@sanity/codegen/src/typescript/findQueriesInPath.ts @@ -25,6 +25,7 @@ type ResultQueries = { type ResultError = { type: 'error' error: Error + filename: string } /** @@ -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} } } } diff --git a/packages/sanity/package.config.ts b/packages/sanity/package.config.ts index 9fd86115ca5..52b891b7098 100644 --- a/packages/sanity/package.config.ts +++ b/packages/sanity/package.config.ts @@ -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: { diff --git a/packages/sanity/package.json b/packages/sanity/package.json index d28999ab7d8..d8d162118f4 100644 --- a/packages/sanity/package.json +++ b/packages/sanity/package.json @@ -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", @@ -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", diff --git a/packages/sanity/src/_internal/cli/actions/codegen/generateTypes.telemetry.ts b/packages/sanity/src/_internal/cli/actions/codegen/generateTypes.telemetry.ts new file mode 100644 index 00000000000..16498c9e4d8 --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/codegen/generateTypes.telemetry.ts @@ -0,0 +1,16 @@ +import {defineTrace} from '@sanity/telemetry' + +interface TypesGeneratedTraceAttrubutes { + outputSize: number + queryTypes: number + schemaTypes: number + files: number + filesWithErrors: number + unknownQueries: number +} + +export const TypesGeneratedTrace = defineTrace({ + name: 'Types Generated', + version: 0, + description: 'Trace emitted when generating TypeScript types for queries', +}) diff --git a/packages/sanity/src/_internal/cli/actions/codegen/generateTypesAction.ts b/packages/sanity/src/_internal/cli/actions/codegen/generateTypesAction.ts new file mode 100644 index 00000000000..a3fd9da7fcd --- /dev/null +++ b/packages/sanity/src/_internal/cli/actions/codegen/generateTypesAction.ts @@ -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, + context: CliCommandContext, +): Promise { + 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((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, + unknownQueries: 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}`, + ) +} diff --git a/packages/sanity/src/_internal/cli/commands/codegen/generateTypesCommand.ts b/packages/sanity/src/_internal/cli/commands/codegen/generateTypesCommand.ts new file mode 100644 index 00000000000..41767fcfa8b --- /dev/null +++ b/packages/sanity/src/_internal/cli/commands/codegen/generateTypesCommand.ts @@ -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 diff --git a/packages/sanity/src/_internal/cli/commands/index.ts b/packages/sanity/src/_internal/cli/commands/index.ts index e27daee4cce..051b9476e65 100644 --- a/packages/sanity/src/_internal/cli/commands/index.ts +++ b/packages/sanity/src/_internal/cli/commands/index.ts @@ -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' @@ -97,6 +98,7 @@ const commands: (CliCommandDefinition | CliCommandGroupDefinition)[] = [ queryDocumentsCommand, deleteDocumentsCommand, createDocumentsCommand, + generateTypesCodegenCommand, validateDocumentsCommand, graphqlGroup, listGraphQLAPIsCommand, diff --git a/packages/sanity/src/_internal/cli/threads/codegenGenerateTypes.ts b/packages/sanity/src/_internal/cli/threads/codegenGenerateTypes.ts new file mode 100644 index 00000000000..471fb515a0b --- /dev/null +++ b/packages/sanity/src/_internal/cli/threads/codegenGenerateTypes.ts @@ -0,0 +1,157 @@ +import { + findQueriesInPath, + getResolver, + readSchema, + registerBabel, + TypeGenerator, +} from '@sanity/codegen' +import createDebug from 'debug' +import {parse, typeEvaluate, type TypeNode} from 'groq-js' +import {isMainThread, parentPort, workerData as _workerData} from 'worker_threads' + +const $info = createDebug('sanity:codegen:generate:info') + +export interface CodegenGenerateTypesWorkerData { + workDir: string + workspaceName?: string + schemaPath: string + searchPath: string | string[] +} + +export type CodegenGenerateTypesWorkerMessage = + | { + type: 'error' + error: Error + fatal: boolean + query?: string + filename?: string + } + | { + type: 'types' + filename: string + types: { + queryName: string + query: string + type: string + unknownTypes: number + }[] + } + | { + type: 'schema' + filename: string + schema: string + length: number + } + | { + type: 'complete' + } + +if (isMainThread || !parentPort) { + throw new Error('This module must be run as a worker thread') +} + +const opts = _workerData as CodegenGenerateTypesWorkerData + +registerBabel() + +async function main() { + const schema = await readSchema(opts.schemaPath) + + const typeGenerator = new TypeGenerator(schema) + const schemaTypes = typeGenerator.generateSchemaTypes() + const resolver = getResolver() + + parentPort?.postMessage({ + type: 'schema', + schema: schemaTypes, + filename: 'schema.json', + length: schema.length, + } satisfies CodegenGenerateTypesWorkerMessage) + + const queries = findQueriesInPath({ + path: opts.searchPath, + resolver, + }) + + for await (const result of queries) { + if (result.type === 'error') { + parentPort?.postMessage({ + type: 'error', + error: result.error, + fatal: false, + filename: result.filename, + } satisfies CodegenGenerateTypesWorkerMessage) + continue + } + $info(`Processing ${result.queries.length} queries in "${result.filename}"...`) + + const fileQueryTypes: {queryName: string; query: string; type: string; unknownTypes: number}[] = + [] + for (const {name: queryName, result: query} of result.queries) { + try { + const ast = parse(query) + const queryTypes = typeEvaluate(ast, schema) + + const type = typeGenerator.generateTypeNodeTypes(queryName, queryTypes) + + fileQueryTypes.push({ + queryName: queryName, + query, + type, + unknownTypes: countUnknownTypes(queryTypes), + }) + } catch (err) { + parentPort?.postMessage({ + type: 'error', + error: new Error( + `Error generating types for query "${queryName}" in "${result.filename}": ${err.message}`, + {cause: err}, + ), + fatal: false, + query, + } satisfies CodegenGenerateTypesWorkerMessage) + } + } + + if (fileQueryTypes.length > 0) { + $info(`Generated types for ${fileQueryTypes.length} queries in "${result.filename}"\n`) + parentPort?.postMessage({ + type: 'types', + types: fileQueryTypes, + filename: result.filename, + } satisfies CodegenGenerateTypesWorkerMessage) + } + } + + parentPort?.postMessage({ + type: 'complete', + } satisfies CodegenGenerateTypesWorkerMessage) +} + +function countUnknownTypes(typeNode: TypeNode): number { + switch (typeNode.type) { + case 'unknown': + return 1 + case 'array': + return countUnknownTypes(typeNode.of) + case 'object': + // if the rest is unknown, we count it as one unknown type + if (typeNode.rest && typeNode.rest.type === 'unknown') { + return 1 + } + + return ( + Object.values(typeNode.attributes).reduce( + (acc, attribute) => acc + countUnknownTypes(attribute.value), + 0, + ) + (typeNode.rest ? countUnknownTypes(typeNode.rest) : 0) + ) + case 'union': + return typeNode.of.reduce((acc, type) => acc + countUnknownTypes(type), 0) + + default: + return 0 + } +} + +main() diff --git a/packages/sanity/tsconfig.json b/packages/sanity/tsconfig.json index e1ffb17e9a9..ea48f170b2f 100644 --- a/packages/sanity/tsconfig.json +++ b/packages/sanity/tsconfig.json @@ -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"} ] } diff --git a/packages/sanity/tsconfig.lib.json b/packages/sanity/tsconfig.lib.json index f0f87a09209..32381ad3e37 100644 --- a/packages/sanity/tsconfig.lib.json +++ b/packages/sanity/tsconfig.lib.json @@ -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"} ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebc5b33f9be..4a6575beb6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -872,9 +872,15 @@ importers: groq-js: specifier: 1.5.0-canary.1 version: 1.5.0-canary.1 + json5: + specifier: ^2.2.3 + version: 2.2.3 tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@jest/globals': specifier: ^29.7.0 @@ -1437,6 +1443,9 @@ importers: '@sanity/client': specifier: ^6.15.5 version: 6.15.5 + '@sanity/codegen': + specifier: workspace:* + version: link:../@sanity/codegen '@sanity/color': specifier: ^3.0.0 version: 3.0.0 @@ -1585,8 +1594,8 @@ importers: specifier: 1.0.2 version: 1.0.2 groq-js: - specifier: ^1.1.12 - version: 1.4.3 + specifier: 1.5.0-canary.1 + version: 1.5.0-canary.1 hashlru: specifier: ^2.3.0 version: 2.3.0