Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions packages/core/src/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,68 @@ describe("generateSystemPrompt", () => {
expect(prompt).toContain("phoneNumber");
expect(prompt).toContain("zipCode");
});

it("formats discriminatedUnion props with correct literal types", () => {
const catalog = createCatalog({
components: {
Container: {
props: z.object({
content: z.discriminatedUnion("type", [
z.object({
type: z.literal("text"),
text: z.string(),
variant: z.enum(["default", "heading", "caption"]).optional(),
}),
z.object({
type: z.literal("image"),
url: z.string(),
alt: z.string().optional(),
}),
]),
}),
description: "A container",
},
},
});

const prompt = generateSystemPrompt(catalog);

// discriminator field should show literal values, not "undefined"
expect(prompt).toContain('type: "text"');
expect(prompt).toContain('type: "image"');
expect(prompt).not.toContain("type: undefined");

// other fields should still render correctly
expect(prompt).toContain("text: string");
expect(prompt).toContain("url: string");
expect(prompt).toContain("alt?: string");
expect(prompt).toContain('"default" | "heading" | "caption"');
});

it("formats nativeEnum props correctly", () => {
enum Status {
Active = "active",
Inactive = "inactive",
Pending = "pending",
}

const catalog = createCatalog({
components: {
Badge: {
props: z.object({
status: z.enum(Status),
label: z.string().optional(),
}),
description: "Status badge",
},
},
});

const prompt = generateSystemPrompt(catalog);

expect(prompt).toContain('"active" | "inactive" | "pending"');
expect(prompt).toContain("label?: string");
});
});

describe("defineCatalog (new schema API)", () => {
Expand Down
73 changes: 33 additions & 40 deletions packages/core/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,11 +298,12 @@ export type InferCatalogComponentProps<
* Internal Zod definition type for introspection
*/
interface ZodDefInternal {
typeName?: string;
type?: string;
value?: unknown;
values?: unknown;
type?: z.ZodTypeAny;
shape?: () => Record<string, z.ZodTypeAny>;
entries?: Record<string, string>;
element?: z.ZodTypeAny;
shape?: Record<string, z.ZodTypeAny>;
innerType?: z.ZodTypeAny;
options?: z.ZodTypeAny[];
}
Expand All @@ -312,77 +313,72 @@ interface ZodDefInternal {
*/
function formatZodType(schema: z.ZodTypeAny, isOptional = false): string {
const def = schema._def as unknown as ZodDefInternal;
const typeName = def.typeName ?? "";
const typeName = def.type ?? "";

let result: string;

switch (typeName) {
case "ZodString":
case "string":
result = "string";
break;
case "ZodNumber":
case "number":
result = "number";
break;
case "ZodBoolean":
case "boolean":
result = "boolean";
break;
case "ZodLiteral":
result = JSON.stringify(def.value);
case "literal":
result = (def.values as unknown[])
.map((v) => JSON.stringify(v))
.join(" | ");
break;
case "ZodEnum":
result = (def.values as string[]).map((v) => `"${v}"`).join("|");
break;
case "ZodNativeEnum":
result = Object.values(def.values as Record<string, string>)
case "enum":
result = Object.values(def.entries as Record<string, string>)
.map((v) => `"${v}"`)
.join("|");
.join(" | ");
break;
case "ZodArray":
result = def.type
? `Array<${formatZodType(def.type)}>`
case "array":
result = def.element
? `Array<${formatZodType(def.element)}>`
: "Array<unknown>";
break;
case "ZodObject": {
case "object": {
if (!def.shape) {
result = "object";
break;
}
const shape = def.shape();
const props = Object.entries(shape)
const props = Object.entries(def.shape)
.map(([key, value]) => {
const innerDef = value._def as unknown as ZodDefInternal;
const innerOptional =
innerDef.typeName === "ZodOptional" ||
innerDef.typeName === "ZodNullable";
innerDef.type === "optional" || innerDef.type === "nullable";
return `${key}${innerOptional ? "?" : ""}: ${formatZodType(value)}`;
})
.join(", ");
result = `{ ${props} }`;
break;
}
case "ZodOptional":
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
case "ZodNullable":
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
case "ZodDefault":
case "optional":
case "nullable":
case "default":
return def.innerType
? formatZodType(def.innerType, isOptional)
: "unknown";
case "ZodUnion":
case "union":
result = def.options
? def.options.map((opt) => formatZodType(opt)).join("|")
? def.options.map((opt) => formatZodType(opt)).join(" | ")
: "unknown";
break;
case "ZodNull":
case "null":
result = "null";
break;
case "ZodUndefined":
case "undefined":
result = "undefined";
break;
case "ZodAny":
case "any":
result = "any";
break;
case "ZodUnknown":
case "unknown":
result = "unknown";
break;
default:
Expand All @@ -399,18 +395,15 @@ function extractPropsFromSchema(
schema: z.ZodTypeAny,
): Array<{ name: string; type: string; optional: boolean }> {
const def = schema._def as unknown as ZodDefInternal;
const typeName = def.typeName ?? "";

if (typeName !== "ZodObject" || !def.shape) {
if (def.type !== "object" || !def.shape) {
return [];
}

const shape = def.shape();
return Object.entries(shape).map(([name, value]) => {
return Object.entries(def.shape).map(([name, value]) => {
const innerDef = value._def as unknown as ZodDefInternal;
const optional =
innerDef.typeName === "ZodOptional" ||
innerDef.typeName === "ZodNullable";
innerDef.type === "optional" || innerDef.type === "nullable";
return {
name,
type: formatZodType(value),
Expand Down