stylebot-harmony/scripts/build-models.ts
2023-08-03 20:09:32 -04:00

322 lines
8.5 KiB
JavaScript
Executable File

#!/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<string, string>
}
const FIELD_REGEX =
/^(?<name>\w+)(?<optional>\?)?(:\s*(?<type>[\w\s[\]|]+))?(\s*=\s*(?<defaultValue>.+))?$/
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<string | false | undefined, void, unknown>
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<string, string[]>
imports?: string
models: Record<string, Model>
}
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<string>()
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)
})
}