98 lines
3.2 KiB
TypeScript
98 lines
3.2 KiB
TypeScript
import { z } from "zod"
|
|
import { parse, type TemplateVariable } from "./parser"
|
|
import { renderDir, type RenderOptions } from "./render"
|
|
|
|
export type { RenderOptions, ReverseMapFile, ReverseMapManifest, ReverseMapToken } from "./render"
|
|
|
|
interface Stringable {
|
|
toString: () => string
|
|
}
|
|
|
|
interface Issue {
|
|
message: string,
|
|
path: Stringable
|
|
}
|
|
|
|
export class SchemaMismatchError extends Error {
|
|
constructor(issue: Issue) {
|
|
super(`Schema doesn't match used template variables: ${issue.path}: ${issue.message}` )
|
|
this.name = "SchemaMismatchError"
|
|
}
|
|
}
|
|
|
|
function zodTypeName(schema: z.ZodType): string {
|
|
if (schema instanceof z.ZodObject) return "object"
|
|
if (schema instanceof z.ZodString) return "string"
|
|
if (schema instanceof z.ZodBoolean) return "boolean"
|
|
if (schema instanceof z.ZodNumber) return "number"
|
|
return schema.constructor.name
|
|
}
|
|
|
|
function zodDisplayName(schema: z.ZodType): string {
|
|
const type = zodTypeName(schema)
|
|
return type.startsWith("Zod") ? type : `z.${type}()`
|
|
}
|
|
|
|
function setExpectedType(expected: Map<string, string>, path: string, type: string) {
|
|
const existing = expected.get(path)
|
|
if (existing && existing !== type) {
|
|
throw new SchemaMismatchError({
|
|
path,
|
|
message: `conflicting template variable types: expected both z.${existing}() and z.${type}()`,
|
|
})
|
|
}
|
|
expected.set(path, type)
|
|
}
|
|
|
|
function validateSchemaMatchesTemplates(userSchema: z.ZodType, variables: TemplateVariable[]) {
|
|
if (!(userSchema instanceof z.ZodObject)) {
|
|
throw new SchemaMismatchError({ path: "", message: "Schema must be a z.object()" })
|
|
}
|
|
|
|
// Collect all expected paths: leaf variables + intermediate segments must be objects
|
|
const expected = new Map<string, string>()
|
|
for (const v of variables) {
|
|
const segments = v.path.split(".")
|
|
for (let i = 0; i < segments.length - 1; i++) {
|
|
const intermediate = segments.slice(0, i + 1).join(".")
|
|
setExpectedType(expected, intermediate, "object")
|
|
}
|
|
setExpectedType(expected, v.path, v.type)
|
|
}
|
|
|
|
for (const [path, expectedType] of expected) {
|
|
const segments = path.split(".")
|
|
let current: z.ZodType = userSchema
|
|
let currentPath = ""
|
|
for (const seg of segments) {
|
|
currentPath = currentPath ? `${currentPath}.${seg}` : seg
|
|
if (!(current instanceof z.ZodObject)) {
|
|
throw new SchemaMismatchError({ path: currentPath, message: `expected z.object() but schema has ${zodDisplayName(current)}` })
|
|
}
|
|
const shape = current.shape
|
|
if (!(seg in shape)) {
|
|
throw new SchemaMismatchError({ path: currentPath, message: `missing in schema` })
|
|
}
|
|
current = shape[seg]!
|
|
}
|
|
const actual = zodTypeName(current)
|
|
if (actual !== expectedType) {
|
|
throw new SchemaMismatchError({ path, message: `expected z.${expectedType}() but schema has ${zodDisplayName(current)}` })
|
|
}
|
|
}
|
|
}
|
|
|
|
export const initRenderer = (dirPath: string) => {
|
|
const variables = parse(dirPath)
|
|
|
|
const createRenderer = <S extends z.ZodType>(userSchema: S) => {
|
|
validateSchemaMatchesTemplates(userSchema, variables)
|
|
return (targetPath: string, context: z.infer<S>, options?: RenderOptions) => {
|
|
userSchema.parse(context)
|
|
renderDir(dirPath, targetPath, context as Record<string, unknown>, options)
|
|
}
|
|
}
|
|
|
|
return createRenderer
|
|
}
|