#!/usr/bin/env -S node -r esbin import "dotenv/config" import { promises as fs } from "fs" import { resolve } from "path" import { assign, camelCase } from "lodash" import yaml from "js-yaml" import { writeFormatted } from "./utils" // type Outputs = "partial input" | "input" | "object" | "inputAndObject" | "args" | "zod"; type Output = | ["input", { partial?: boolean; name?: string }] // | ["object"] | ["args"] | ["zod"] interface Model { as: Output[] extends?: string fields: string extra?: Record } const FIELD_REGEX = /^(?\w+)(?\?)?(:\s*(?[\w\s[\]|]+))?(\s*=\s*(?.+))?$/ function parseField(field: string) { const groups = field.match(FIELD_REGEX)?.groups if (!groups) { throw new Error(`Invalid field: ${field}`) } if (!groups.type && groups.defaultValue) { const v = groups.defaultValue if (v === "true" || v === "false") { groups.type = "boolean" } } return groups as { name: string optional?: "?" type: string defaultValue: string } } function parseFields(value: any) { if (typeof value === "object") { value = Object.entries(value) .map(([name, type]) => `${name}: ${type}`) .join("\n") } return (value as string).trim().split("\n").map(parseField) } const getInputName = (name: string) => (name.startsWith("Input") ? name : `Input${name}`) type Generate = Generator async function main() { const folder = resolve(__dirname, "../src/shared/models") const source = await fs.readFile(resolve(folder, "models.yml"), "utf8") const json = yaml.load(source) as { enums: Record imports?: string models: Record } const models = Object.entries(json.models).map( ([name, { as, extends: _, fields, extra }]) => ({ name, as: as.map(t => Array.isArray(t) ? [t[0], (assign as any)(...t.slice(1))] : [t, {}], ) as Output[], extends: _, fields: parseFields(fields), extra, }), ) for (const model of models) { if (model.extends) { const parent = models.find(m => m.name === model.extends) if (!parent) { throw new Error(`Could not find parent ${model.extends}`) } model.fields = parent.fields.concat(model.fields) } } const importedEnums = new Set() const enumNames = Object.keys(json.enums) function getReifiedType(type: string): string { if (type.endsWith("| null")) { type = type.slice(0, -6).trim() } if (type.endsWith("[]")) { return `[${getReifiedType(type.slice(0, -2))}]` } switch (type) { case "string": case "String": return "String" case "int": case "Int": return "Int" case "boolean": case "Boolean": return "Boolean" default: if (enumNames.includes(type)) { importedEnums.add(type) } if (/^\d+$/.test(type)) { return "Int" } return type } } function getVirtualType(type: string): string { if (type.endsWith("[]")) { return `${getVirtualType(type.slice(0, -2))}[]` } else if (type.endsWith("| null")) { return `${getVirtualType(type.slice(0, -6).trim())} | null` } switch (type) { case "int": case "Int": return "number" case "ID": case "String": return "string" case "Boolean": return "boolean" default: if (enumNames.includes(type)) { importedEnums.add(type) } return type } } function getZodType(type: string): string { if (type.endsWith("[]")) { return `z.array(${getZodType(type.slice(0, -2))})` } else if (type.endsWith("| null")) { return `${getZodType(type.slice(0, -6).trim())}.optional()` } switch (type) { case "int": case "Int": return "z.number()" case "string": return "z.string()" case "boolean": return "z.boolean()" default: if (/^[\d.]+$/.test(type)) { return `z.literal(${type})` } if (enumNames.includes(type)) { return `z.nativeEnum(${type})` } return `z.instanceof(${type})` } } const banners: string[] = [ `import { ArgsType, Field, ID, InputType, Int, ObjectType } from "@aet/gql-tools/macro";`, // `import { z } from "zod";`, ] /* eslint-disable @typescript-eslint/consistent-type-imports, @typescript-eslint/no-unused-vars, @typescript-eslint/no-inferrable-types */ const getModels = function* (): Generate { for (const model of models) { let attachInputToObject = false for (const type of model.as) { let { name } = model let isInput = false let isPartialInput = false switch (type[0]) { case "input": isInput = true isPartialInput = type[1].partial ?? false if ( Object.keys(type[1]).length === 0 && model.as.some(t => t[0] === "object") ) { attachInputToObject = true continue } else { name = type[1].name ?? getInputName(name) yield `@InputType("${name}")\n` } break case "object": if (attachInputToObject) { yield `/** Input and object type for ${name} */\n` yield `@InputType("${getInputName(name)}")\n` } yield `@ObjectType("${name}")\n` break case "args": yield `@ArgsType()\n` break case "zod": continue } yield `export class ${name} {` for (const field of model.fields) { const optional = field.optional || isPartialInput yield "@Field(" yield `() => ${getReifiedType(field.type)},` if (optional) { yield "{ nullable: true }," } yield ")\n" if (!field.defaultValue) { yield "declare " } yield `${field.name}` if (optional) { yield "?" } yield ":" yield getVirtualType(field.type) if (isInput && field.defaultValue) { yield ` = ${field.defaultValue}` } yield ";\n\n" } let constructorType = name if (model.extra) { const omits: string[] = [] for (const [name, type] of Object.entries(model.extra)) { if (type.includes("=")) { omits.push(name.replace(/\?$/, "")) } yield `${name}: ${type}\n` } constructorType = `Omit<${name}, ${omits.map(o => `"${o}"`).join(" | ")}>` } yield /* js */ ` /** * Create an instance of ${name} from a plain object. */ constructor(fields: ${constructorType}) { Object.assign(this, fields); } ` // if (1 === 3) { // yield "\n"; // yield `/** Zod schema for ${name} */\n`; // yield "static zod = z.object({\n"; // for (const field of model.fields) { // yield `${field.name}: ${getZodType(field.type)},\n`; // } // yield `}).strict();`; // } yield "}\n\n" } } } function* getEnums() { for (const [name, values] of Object.entries(json.enums)) { yield `export const ${name} = {` for (const value of values) { yield `${value}: "${value}",\n` } yield "} as const;\n" yield yield `export type ${name} = typeof ${name}[keyof typeof ${name}];\n` yield yield `export const ${camelCase(name)}s = [${values .map(v => `"${v}"`) .join(", ")}] as ${name}[];\n` yield } } await writeTS(resolve(folder, "models.generated.ts"), getModels, () => banners.concat( importedEnums.size ? `import { ${Array.from(importedEnums) .sort() .join(", ")} } from "./enums.generated";` : [], ), ) await writeTS(resolve(folder, "enums.generated.ts"), getEnums, () => []) } async function writeTS(path: string, generator: () => Generate, banner: () => string[]) { const code = Array.from(generator(), x => x ?? "\n").join("") await writeFormatted(path, banner().join("\n") + "\n\n" + code) } if (require.main === module) { Promise.resolve() .then(() => main()) .catch(e => { console.error(e) process.exit(1) }) }