diff --git a/internal/codegen/output.go b/internal/codegen/output.go index 77e65221b..056f92de5 100644 --- a/internal/codegen/output.go +++ b/internal/codegen/output.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/cog/internal/jennies/python" "github.com/grafana/cog/internal/jennies/terraform" "github.com/grafana/cog/internal/jennies/typescript" + "github.com/grafana/cog/internal/jennies/zod" ) type Output struct { @@ -69,6 +70,7 @@ type OutputLanguage struct { Python *python.Config `yaml:"python"` Typescript *typescript.Config `yaml:"typescript"` Terraform *terraform.Config `yaml:"terraform"` + Zod *zod.Config `yaml:"zod"` } func (outputLanguage *OutputLanguage) interpolateParameters(output *Output, interpolator ParametersInterpolator) { @@ -96,6 +98,10 @@ func (outputLanguage *OutputLanguage) interpolateParameters(output *Output, inte if outputLanguage.Terraform != nil { outputLanguage.Terraform.InterpolateParameters(interpolator) } + + if outputLanguage.Zod != nil { + outputLanguage.Zod.InterpolateParameters(interpolator) + } } type OutputOptions struct { diff --git a/internal/codegen/pipeline.go b/internal/codegen/pipeline.go index 0084f9691..7f842fe1e 100644 --- a/internal/codegen/pipeline.go +++ b/internal/codegen/pipeline.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/cog/internal/jennies/python" "github.com/grafana/cog/internal/jennies/terraform" "github.com/grafana/cog/internal/jennies/typescript" + "github.com/grafana/cog/internal/jennies/zod" "github.com/grafana/cog/internal/languages" "github.com/grafana/cog/internal/logs" "github.com/grafana/cog/internal/tools" @@ -321,6 +322,8 @@ func (pipeline *Pipeline) OutputLanguages() (languages.Languages, error) { outputs[typescript.LanguageRef] = typescript.New(*output.Typescript) case output.Terraform != nil: outputs[terraform.LanguageRef] = terraform.New(*output.Terraform) + case output.Zod != nil: + outputs[zod.LanguageRef] = zod.New(*output.Zod) default: return nil, fmt.Errorf("empty language configuration") } diff --git a/internal/jennies/zod/jennies.go b/internal/jennies/zod/jennies.go new file mode 100644 index 000000000..d13926775 --- /dev/null +++ b/internal/jennies/zod/jennies.go @@ -0,0 +1,118 @@ +package zod + +import ( + "github.com/grafana/codejen" + "github.com/grafana/cog/internal/ast/compiler" + "github.com/grafana/cog/internal/jennies/common" + "github.com/grafana/cog/internal/languages" + "github.com/grafana/cog/internal/tools" +) + +const LanguageRef = "zod" + +type Config struct { + // PathPrefix is the directory prefix for generated files. Defaults to "src". + PathPrefix *string `yaml:"path_prefix"` + + // Filename overrides the per-package output filename. Defaults to + // "schemas.gen.ts". + Filename *string `yaml:"filename"` + + // FlatLayout drops the per-package directory and writes directly into + // PathPrefix. Useful when the consumer manages its own layout. + FlatLayout bool `yaml:"flat_layout"` + + // OptionalKindLiterals turns required fields whose type is a concrete + // string literal (e.g. `kind: "Panel"`) into `.optional().default("X")`, + // so callers don't have to repeat the discriminator. The literal still + // constrains values that are supplied. Implies VerboseDefaults so the + // generated file uses one form for every defaulted field. + OptionalKindLiterals bool `yaml:"optional_kind_literals"` + + // ObjectMode controls how unknown keys are handled at parse time: + // - "strip" (default): unknown keys are silently dropped (Zod's default). + // - "strict": unknown keys cause parse errors. + // - "loose": unknown keys are kept on the parsed output. + ObjectMode string `yaml:"object_mode"` + + // PreferUnknownForAny emits `z.unknown()` instead of `z.any()` for CUE's + // top type (`_`), forcing consumers to narrow before use. + PreferUnknownForAny bool `yaml:"prefer_unknown_for_any"` + + // VerboseDefaults emits defaulted fields as `.optional().default(X)` + // rather than `.default(X).optional()`. The verbose form fixes the + // inferred output type, which would otherwise include `undefined` even + // though the default always fires. Implied by OptionalKindLiterals. + VerboseDefaults bool `yaml:"verbose_defaults"` +} + +func (config *Config) InterpolateParameters(interpolator func(input string) string) { + if config.PathPrefix != nil { + config.PathPrefix = tools.ToPtr(interpolator(*config.PathPrefix)) + } + if config.Filename != nil { + config.Filename = tools.ToPtr(interpolator(*config.Filename)) + } +} + +func (config *Config) applyDefaults() { + if config.PathPrefix == nil { + config.PathPrefix = tools.ToPtr("src") + } + if config.Filename == nil { + config.Filename = tools.ToPtr("schemas.gen.ts") + } + if config.ObjectMode == "" { + config.ObjectMode = "strip" + } +} + +type Language struct { + config Config +} + +func New(config Config) *Language { + return &Language{ + config: config, + } +} + +func (language *Language) Name() string { + return LanguageRef +} + +func (language *Language) Jennies(globalConfig languages.Config) *codejen.JennyList[languages.Context] { + language.config.applyDefaults() + + jenny := codejen.JennyListWithNamer(func(_ languages.Context) string { + return LanguageRef + }) + + jenny.AppendOneToMany( + common.If(globalConfig.Types, RawTypes{config: language.config}), + ) + + jenny.AddPostprocessors(common.GeneratedCommentHeader(globalConfig)) + + return jenny +} + +func (language *Language) CompilerPasses() compiler.Passes { + // Order matters: each pass assumes the shape produced by the previous one. + return compiler.Passes{ + // `T | null` → `T?` so we can attach .nullable() at the call site. + &compiler.DisjunctionWithNullToOptional{}, + // `"a" | "b" | "c"` → enum, so we emit z.enum instead of a union of literals. + &compiler.DisjunctionOfConstantsToEnum{}, + // Flatten before DisjunctionInferMapping, which needs flat disjunctions + // to spot a shared discriminator. + &compiler.FlattenDisjunctions{}, + // Sets Discriminator on unions of struct refs that share a scalar field, + // enabling z.discriminatedUnion instead of z.union. + &compiler.DisjunctionInferMapping{}, + } +} + +func (language *Language) NullableKinds() languages.NullableConfig { + return languages.NullableConfig{} +} diff --git a/internal/jennies/zod/rawtypes.go b/internal/jennies/zod/rawtypes.go new file mode 100644 index 000000000..73f355c2e --- /dev/null +++ b/internal/jennies/zod/rawtypes.go @@ -0,0 +1,513 @@ +package zod + +import ( + "fmt" + "sort" + "strings" + + "github.com/grafana/codejen" + "github.com/grafana/cog/internal/ast" + "github.com/grafana/cog/internal/jennies/common" + "github.com/grafana/cog/internal/languages" + "github.com/grafana/cog/internal/tools" +) + +type RawTypes struct { + config Config +} + +func (jenny RawTypes) JennyName() string { + return "ZodRawTypes" +} + +func (jenny RawTypes) Generate(context languages.Context) (codejen.Files, error) { + // flat_layout puts every schema at /, so multiple + // schemas would collide. Fail rather than silently overwrite. + if jenny.config.FlatLayout && len(context.Schemas) > 1 { + pkgs := make([]string, 0, len(context.Schemas)) + for _, schema := range context.Schemas { + pkgs = append(pkgs, schema.Package) + } + sort.Strings(pkgs) + return nil, fmt.Errorf( + "zod jenny: flat_layout cannot be combined with multiple input schemas — files would collide at the same path (got %d schemas: %s)", + len(context.Schemas), strings.Join(pkgs, ", "), + ) + } + + files := make(codejen.Files, 0, len(context.Schemas)) + + for _, schema := range context.Schemas { + output, err := jenny.generateSchema(context, schema) + if err != nil { + return nil, err + } + + filename := jenny.config.outputFilePath(formatPackageName(schema.Package)) + + files = append(files, *codejen.NewFile(filename, output, jenny)) + } + + return files, nil +} + +func (jenny RawTypes) generateSchema(context languages.Context, schema *ast.Schema) ([]byte, error) { + imports := newImportMap() + + // flat_layout drops per-package dirs, breaking the "../" import + // pattern. Collect cross-package refs and fail later, rather than emit + // an unresolvable import. + var crossPkgRefs []string + seenCrossPkgRefs := make(map[string]struct{}) + + pkgMapper := func(pkg string) string { + if pkg == "" || pkg == schema.Package { + return "" + } + alias := formatPackageName(pkg) + if jenny.config.FlatLayout { + if _, seen := seenCrossPkgRefs[pkg]; !seen { + seenCrossPkgRefs[pkg] = struct{}{} + crossPkgRefs = append(crossPkgRefs, pkg) + } + } + return imports.Add(alias, "../"+alias) + } + + em := &emitter{ + context: context, + config: jenny.config, + pkg: schema.Package, + packageMapper: pkgMapper, + } + + var body strings.Builder + schema.Objects.Iterate(func(_ string, object ast.Object) { + body.WriteString(em.formatObject(object)) + body.WriteString("\n") + }) + + if len(crossPkgRefs) > 0 { + sort.Strings(crossPkgRefs) + return nil, fmt.Errorf( + "zod jenny: flat_layout is incompatible with cross-package references; schema %q references %s, and import paths cannot be resolved without per-package directories — disable flat_layout or eliminate the cross-package reference", + schema.Package, strings.Join(crossPkgRefs, ", "), + ) + } + + header := "" + if schemaHasCycle(schema) { + header += "// @ts-nocheck\n" + } + header += "import { z } from 'zod';\n" + if importStatements := imports.String(); importStatements != "" { + header += importStatements + } + header += "\n" + + return []byte(header + body.String()), nil +} + +// schemaHasCycle returns true when any object in the schema participates in a +// cycle via same-package references. Recursive Zod schemas built with z.lazy() +// trip TS strict-mode declaration-emit (TS7022/TS7024), and only the +// cycle-bearing files need the @ts-nocheck suppression. Cycle detection is a +// standard 3-color DFS over the same-package reference graph. +func schemaHasCycle(schema *ast.Schema) bool { + adj := make(map[string]map[string]struct{}) + schema.Objects.Iterate(func(name string, obj ast.Object) { + refs := make(map[string]struct{}) + collectSamePackageRefs(obj.Type, schema.Package, refs) + adj[name] = refs + }) + + const ( + white = 0 + gray = 1 + black = 2 + ) + color := make(map[string]int, len(adj)) + + var dfs func(string) bool + dfs = func(node string) bool { + switch color[node] { + case gray: + return true + case black: + return false + } + color[node] = gray + for next := range adj[node] { + if dfs(next) { + return true + } + } + color[node] = black + return false + } + + for node := range adj { + if color[node] == white && dfs(node) { + return true + } + } + return false +} + +// collectSamePackageRefs walks t and records every referenced object name in +// the same package. Cross-package refs and scalars are skipped because they +// cannot close a cycle within this file. +func collectSamePackageRefs(t ast.Type, pkg string, out map[string]struct{}) { + switch t.Kind { + case ast.KindRef: + r := t.AsRef() + if r.ReferredPkg == "" || r.ReferredPkg == pkg { + out[r.ReferredType] = struct{}{} + } + case ast.KindStruct: + for _, f := range t.AsStruct().Fields { + collectSamePackageRefs(f.Type, pkg, out) + } + case ast.KindArray: + collectSamePackageRefs(t.AsArray().ValueType, pkg, out) + case ast.KindMap: + collectSamePackageRefs(t.AsMap().ValueType, pkg, out) + case ast.KindDisjunction: + for _, b := range t.AsDisjunction().Branches { + collectSamePackageRefs(b, pkg, out) + } + case ast.KindIntersection: + for _, b := range t.AsIntersection().Branches { + collectSamePackageRefs(b, pkg, out) + } + } +} + +// emitter walks the AST for one schema and produces Zod source as strings. +type emitter struct { + context languages.Context + config Config + pkg string + packageMapper func(pkg string) string +} + +// stringLiteralValue returns (value, true) when t is emitted as +// `z.literal("X")` — covers inline string scalars (`kind: "Panel"`) and +// refs to string constants (`mode: RepeatMode` with `RepeatMode = "variable"`). +func stringLiteralValue(t ast.Type) (any, bool) { + switch t.Kind { + case ast.KindScalar: + s := t.AsScalar() + if s.IsConcrete() && s.ScalarKind == ast.KindString { + return s.Value, true + } + case ast.KindConstantRef: + v := t.AsConstantRef().ReferenceValue + if _, ok := v.(string); ok { + return v, true + } + } + return nil, false +} + +func (e *emitter) formatObject(def ast.Object) string { + var buf strings.Builder + + objectName := tools.CleanupNames(def.Name) + + // Constants emit as a plain TS const + typeof alias so consumers can use + // them as both value and type. No schema means no .describe(), so doc + // comments stay as plain JS comments. + if def.Type.IsConcreteScalar() { + for _, comment := range def.Comments { + buf.WriteString("// ") + buf.WriteString(comment) + buf.WriteString("\n") + } + val := def.Type.AsScalar().Value + buf.WriteString(fmt.Sprintf("export const %s = %s;\n", objectName, formatLiteral(val))) + buf.WriteString(fmt.Sprintf("export type %s = typeof %s;\n", objectName, objectName)) + return buf.String() + } + + expr := e.formatType(def.Type) + if desc := joinDescription(def.Comments); desc != "" { + expr += fmt.Sprintf(".describe(%s)", formatLiteral(desc)) + } + + buf.WriteString(fmt.Sprintf("export const %sSchema = %s;\n", objectName, expr)) + return buf.String() +} + +func (e *emitter) formatType(t ast.Type) string { + expr := e.formatBareType(t) + if t.Nullable { + expr += ".nullable()" + } + return expr +} + +func (e *emitter) formatBareType(t ast.Type) string { + switch t.Kind { + case ast.KindStruct: + return e.formatStruct(t) + case ast.KindScalar: + return e.formatScalar(t.AsScalar()) + case ast.KindRef: + return e.formatRef(t.AsRef()) + case ast.KindEnum: + return e.formatEnum(t.AsEnum()) + case ast.KindArray: + return e.formatArray(t.AsArray()) + case ast.KindMap: + return e.formatMap(t.AsMap()) + case ast.KindDisjunction: + return e.formatDisjunction(t.AsDisjunction()) + case ast.KindIntersection: + return e.formatIntersection(t.AsIntersection()) + case ast.KindComposableSlot: + return "z.unknown()" + case ast.KindConstantRef: + return e.formatConstantRef(t.AsConstantRef()) + } + return fmt.Sprintf("z.unknown() /* unhandled kind: %s */", t.Kind) +} + +func (e *emitter) formatStruct(t ast.Type) string { + var buf strings.Builder + + buf.WriteString("z.object({\n") + for _, field := range t.AsStruct().Fields { + fieldExpr := e.formatType(field.Type) + + // OptionalKindLiterals implies VerboseDefaults so the file uses one + // form for every defaulted field. + verboseDefaults := e.config.VerboseDefaults || e.config.OptionalKindLiterals + + litVal, isStringLit := stringLiteralValue(field.Type) + switch { + case e.config.OptionalKindLiterals && + field.Required && + field.Type.Default == nil && + isStringLit: + fieldExpr += fmt.Sprintf(".optional().default(%s)", formatLiteral(litVal)) + + case field.Type.Default != nil && verboseDefaults: + fieldExpr += fmt.Sprintf(".optional().default(%s)", formatLiteral(field.Type.Default)) + + case field.Type.Default != nil: + fieldExpr += fmt.Sprintf(".default(%s)", formatLiteral(field.Type.Default)) + if !field.Required { + fieldExpr += ".optional()" + } + + case !field.Required: + fieldExpr += ".optional()" + } + + if desc := joinDescription(field.Comments); desc != "" { + fieldExpr += fmt.Sprintf(".describe(%s)", formatLiteral(desc)) + } + + buf.WriteString(fmt.Sprintf("\t%s: %s,\n", quoteFieldName(field.Name), fieldExpr)) + } + buf.WriteString("})") + + // CUE open structs (`...`, `[string]: _`) materialize upstream as + // KindMap / KindAny, so anything reaching here is closed. + switch e.config.ObjectMode { + case "strip", "": + case "loose": + buf.WriteString(".loose()") + case "strict": + buf.WriteString(".strict()") + default: + // Unknown mode → strict, so a typo surfaces at parse time. + buf.WriteString(".strict()") + } + + return buf.String() +} + +func (e *emitter) formatScalar(s ast.ScalarType) string { + if s.IsConcrete() { + return fmt.Sprintf("z.literal(%s)", formatLiteral(s.Value)) + } + + for _, c := range s.Constraints { + if c.Op == ast.EqualOp { + return fmt.Sprintf("z.literal(%s)", formatLiteral(c.Args[0])) + } + } + + switch s.ScalarKind { + case ast.KindNull: + return "z.null()" + case ast.KindAny: + if e.config.PreferUnknownForAny { + return "z.unknown()" + } + return "z.any()" + case ast.KindBool: + return "z.boolean()" + case ast.KindBytes, ast.KindString: + return applyStringConstraints("z.string()", s.Constraints) + case ast.KindFloat32, ast.KindFloat64: + return applyNumberConstraints("z.number()", s.Constraints) + case ast.KindUint8, ast.KindUint16, ast.KindUint32, ast.KindUint64, + ast.KindInt8, ast.KindInt16, ast.KindInt32, ast.KindInt64: + return applyNumberConstraints("z.number().int()", s.Constraints) + } + return fmt.Sprintf("z.unknown() /* unhandled scalar: %s */", s.ScalarKind) +} + +func applyStringConstraints(base string, cs []ast.TypeConstraint) string { + for _, c := range cs { + switch c.Op { + case ast.MinLengthOp: + base += fmt.Sprintf(".min(%s)", formatLiteral(c.Args[0])) + case ast.MaxLengthOp: + base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) + case ast.RegexMatchOp: + base += fmt.Sprintf(".regex(new RegExp(%s))", formatLiteral(c.Args[0])) + case ast.NotRegexMatchOp: + base += fmt.Sprintf(".refine((value) => !(new RegExp(%s)).test(value), { message: %q })", formatLiteral(c.Args[0]), "String must not match forbidden pattern") + } + } + return base +} + +func applyNumberConstraints(base string, cs []ast.TypeConstraint) string { + for _, c := range cs { + switch c.Op { + case ast.LessThanOp: + base += fmt.Sprintf(".lt(%s)", formatLiteral(c.Args[0])) + case ast.LessThanEqualOp: + base += fmt.Sprintf(".lte(%s)", formatLiteral(c.Args[0])) + case ast.GreaterThanOp: + base += fmt.Sprintf(".gt(%s)", formatLiteral(c.Args[0])) + case ast.GreaterThanEqualOp: + base += fmt.Sprintf(".gte(%s)", formatLiteral(c.Args[0])) + case ast.MultipleOfOp: + base += fmt.Sprintf(".multipleOf(%s)", formatLiteral(c.Args[0])) + case ast.NotEqualOp: + base += fmt.Sprintf(".refine((value) => value !== %s, { message: %q })", formatLiteral(c.Args[0]), "Value must not equal forbidden constant") + case ast.EqualOp: + base += fmt.Sprintf(".refine((value) => value === %s, { message: %q })", formatLiteral(c.Args[0]), "Value must equal required constant") + } + } + return base +} + +func (e *emitter) formatRef(ref ast.RefType) string { + name := tools.CleanupNames(ref.ReferredType) + "Schema" + if alias := e.packageMapper(ref.ReferredPkg); alias != "" { + name = alias + "." + name + } + // Wrap every cross-reference in z.lazy() to sidestep declaration order. + // TODO: topo-sort objects so only real cycles need z.lazy. + return fmt.Sprintf("z.lazy(() => %s)", name) +} + +func (e *emitter) formatEnum(en ast.EnumType) string { + allString := true + for _, v := range en.Values { + if v.Type.Scalar == nil || v.Type.Scalar.ScalarKind != ast.KindString { + allString = false + break + } + } + + if allString { + parts := tools.Map(en.Values, func(v ast.EnumValue) string { + return formatLiteral(v.Value) + }) + return fmt.Sprintf("z.enum([%s])", strings.Join(parts, ", ")) + } + + parts := tools.Map(en.Values, func(v ast.EnumValue) string { + return fmt.Sprintf("z.literal(%s)", formatLiteral(v.Value)) + }) + return fmt.Sprintf("z.union([%s])", strings.Join(parts, ", ")) +} + +func (e *emitter) formatArray(arr ast.ArrayType) string { + base := fmt.Sprintf("z.array(%s)", e.formatType(arr.ValueType)) + for _, c := range arr.Constraints { + switch c.Op { + case ast.MinItemsOp: + base += fmt.Sprintf(".min(%s)", formatLiteral(c.Args[0])) + case ast.MaxItemsOp: + base += fmt.Sprintf(".max(%s)", formatLiteral(c.Args[0])) + case ast.UniqueItemsOp: + base += `.refine((items) => new Set(items.map((item) => JSON.stringify(item))).size === items.length, { message: "Array items must be unique" })` + } + } + return base +} + +func (e *emitter) formatMap(m ast.MapType) string { + return fmt.Sprintf("z.record(%s, %s)", e.formatType(m.IndexType), e.formatType(m.ValueType)) +} + +func (e *emitter) formatDisjunction(d ast.DisjunctionType) string { + branches := tools.Map(d.Branches, func(b ast.Type) string { + return e.formatType(b) + }) + + // z.discriminatedUnion needs every branch to be a z.object (possibly + // wrapped in z.lazy). HasOnlyRefs alone isn't enough — refs to enums or + // nested unions would make Zod throw at module load. Fall back to + // z.union when we can't prove every branch is a struct ref. + if d.Discriminator != "" && d.Branches.HasOnlyRefs() && e.allBranchesResolveToStruct(d.Branches) { + return fmt.Sprintf("z.discriminatedUnion(%q, [%s])", d.Discriminator, strings.Join(branches, ", ")) + } + + return fmt.Sprintf("z.union([%s])", strings.Join(branches, ", ")) +} + +func (e *emitter) allBranchesResolveToStruct(branches ast.Types) bool { + for _, b := range branches { + if !e.context.ResolveToStruct(b) { + return false + } + } + return true +} + +func (e *emitter) formatIntersection(i ast.IntersectionType) string { + branches := tools.Map(i.Branches, func(b ast.Type) string { + return e.formatType(b) + }) + switch len(branches) { + case 0: + return "z.unknown()" + case 1: + return branches[0] + } + + expr := branches[0] + for _, b := range branches[1:] { + expr = fmt.Sprintf("z.intersection(%s, %s)", expr, b) + } + return expr +} + +func (e *emitter) formatConstantRef(c ast.ConstantReferenceType) string { + return fmt.Sprintf("z.literal(%s)", formatLiteral(c.ReferenceValue)) +} + +func newImportMap() *common.DirectImportMap { + return common.NewDirectImportMap( + common.WithFormatter(func(im common.DirectImportMap) string { + if im.Imports.Len() == 0 { + return "" + } + var b strings.Builder + im.Imports.Iterate(func(alias string, importPath string) { + b.WriteString(fmt.Sprintf("import * as %s from '%s';\n", alias, importPath)) + }) + return b.String() + }), + ) +} diff --git a/internal/jennies/zod/tools.go b/internal/jennies/zod/tools.go new file mode 100644 index 000000000..6610f7871 --- /dev/null +++ b/internal/jennies/zod/tools.go @@ -0,0 +1,111 @@ +package zod + +import ( + "encoding/json" + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func formatPackageName(pkg string) string { + parts := strings.Split(pkg, "/") + if len(parts) > 1 { + pkg = parts[len(parts)-1] + } + rgx := regexp.MustCompile("[^a-zA-Z0-9_]+") + return strings.ToLower(rgx.ReplaceAllString(pkg, "")) +} + +func (config *Config) pathWithPrefix(parts ...string) string { + prefix := "src" + if config.PathPrefix != nil { + prefix = *config.PathPrefix + } + return filepath.Join(append([]string{prefix}, parts...)...) +} + +// outputFilePath returns the generated file path for a (formatted) package +// name, honoring PathPrefix, FlatLayout and Filename. +func (config *Config) outputFilePath(pkg string) string { + filename := "schemas.gen.ts" + if config.Filename != nil && *config.Filename != "" { + filename = *config.Filename + } + if config.FlatLayout { + return config.pathWithPrefix(filename) + } + return config.pathWithPrefix(pkg, filename) +} + +// formatLiteral renders a Go value as a TypeScript literal expression for +// z.literal(...) and .default(...) arguments. +func formatLiteral(val any) string { + if val == nil { + return "null" + } + switch v := val.(type) { + case string: + b, err := json.Marshal(v) + if err != nil { + return "null" + } + return string(b) + case bool: + return fmt.Sprintf("%t", v) + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + return fmt.Sprintf("%v", v) + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + parts = append(parts, formatLiteral(item)) + } + return "[" + strings.Join(parts, ", ") + "]" + case map[string]any: + keys := make([]string, 0, len(v)) + for k := range v { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s: %s", quoteFieldName(k), formatLiteral(v[k]))) + } + return "{" + strings.Join(parts, ", ") + "}" + } + + // Fallback covers numeric types from yaml decoding, json.Number, etc. + b, err := json.Marshal(val) + if err != nil { + return "null" + } + return string(b) +} + +var jsIdentifier = regexp.MustCompile(`^[A-Za-z_$][A-Za-z0-9_$]*$`) + +func quoteFieldName(name string) string { + if jsIdentifier.MatchString(name) { + return name + } + b, err := json.Marshal(name) + if err != nil { + return `""` + } + return string(b) +} + +// joinDescription folds a comment block into a single .describe() argument. +// Returns "" for empty/whitespace-only blocks so callers can skip .describe(). +func joinDescription(comments []string) string { + trimmed := make([]string, 0, len(comments)) + for _, c := range comments { + c = strings.TrimRight(c, " \t") + trimmed = append(trimmed, c) + } + joined := strings.TrimSpace(strings.Join(trimmed, "\n")) + return joined +} diff --git a/schemas/pipeline.json b/schemas/pipeline.json index 73c5ce2c7..518b0418a 100644 --- a/schemas/pipeline.json +++ b/schemas/pipeline.json @@ -273,6 +273,9 @@ }, "terraform": { "$ref": "#/$defs/TerraformConfig" + }, + "zod": { + "$ref": "#/$defs/ZodConfig" } }, "additionalProperties": false, @@ -586,6 +589,40 @@ }, "additionalProperties": false, "type": "object" + }, + "ZodConfig": { + "properties": { + "path_prefix": { + "type": "string", + "description": "PathPrefix is the directory prefix for generated files. Defaults to \"src\"." + }, + "filename": { + "type": "string", + "description": "Filename overrides the per-package output filename. Defaults to\n\"schemas.gen.ts\"." + }, + "flat_layout": { + "type": "boolean", + "description": "FlatLayout drops the per-package directory and writes directly into\nPathPrefix. Useful when the consumer manages its own layout." + }, + "optional_kind_literals": { + "type": "boolean", + "description": "OptionalKindLiterals turns required fields whose type is a concrete\nstring literal (e.g. `kind: \"Panel\"`) into `.optional().default(\"X\")`,\nso callers don't have to repeat the discriminator. The literal still\nconstrains values that are supplied. Implies VerboseDefaults so the\ngenerated file uses one form for every defaulted field." + }, + "object_mode": { + "type": "string", + "description": "ObjectMode controls how unknown keys are handled at parse time:\n - \"strip\" (default): unknown keys are silently dropped (Zod's default).\n - \"strict\": unknown keys cause parse errors.\n - \"loose\": unknown keys are kept on the parsed output." + }, + "prefer_unknown_for_any": { + "type": "boolean", + "description": "PreferUnknownForAny emits `z.unknown()` instead of `z.any()` for CUE's\ntop type (`_`), forcing consumers to narrow before use." + }, + "verbose_defaults": { + "type": "boolean", + "description": "VerboseDefaults emits defaulted fields as `.optional().default(X)`\nrather than `.default(X).optional()`. The verbose form fixes the\ninferred output type, which would otherwise include `undefined` even\nthough the default always fires. Implied by OptionalKindLiterals." + } + }, + "additionalProperties": false, + "type": "object" } } } \ No newline at end of file