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
122 changes: 122 additions & 0 deletions packages/core/src/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,128 @@ describe("catalog.jsonSchema", () => {
expect(jsonSchema).not.toBeNull();
expect(typeof jsonSchema).toBe("object");
});

it("includes a key per component with its props JSON Schema", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
Button: {
props: z.object({ label: z.string(), disabled: z.boolean() }),
description: "",
slots: [],
},
},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
expect(Object.keys(jsonSchema)).toEqual(
expect.arrayContaining(["Text", "Button"]),
);
expect(Object.keys(jsonSchema)).toHaveLength(2);
});

it("serializes string props correctly", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "",
slots: [],
},
},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
expect(jsonSchema.Text.properties.content).toEqual(
expect.objectContaining({ type: "string" }),
);
});

it("serializes number, boolean, and enum props correctly", () => {
const catalog = defineCatalog(testSchema, {
components: {
Widget: {
props: z.object({
count: z.number(),
active: z.boolean(),
variant: z.enum(["primary", "secondary"]),
}),
description: "",
slots: [],
},
},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
const props = jsonSchema.Widget.properties;
expect(props.count).toEqual(expect.objectContaining({ type: "number" }));
expect(props.active).toEqual(expect.objectContaining({ type: "boolean" }));
expect(props.variant.enum).toEqual(["primary", "secondary"]);
});

it("serializes array and nested object props correctly", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
tags: z.array(z.string()),
metadata: z.object({ key: z.string(), value: z.number() }),
}),
description: "",
slots: [],
},
},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
const props = jsonSchema.Card.properties;
expect(props.tags.type).toBe("array");
expect(props.tags.items).toEqual(
expect.objectContaining({ type: "string" }),
);
expect(props.metadata.type).toBe("object");
expect(props.metadata.properties.key).toEqual(
expect.objectContaining({ type: "string" }),
);
expect(props.metadata.properties.value).toEqual(
expect.objectContaining({ type: "number" }),
);
});

it("marks required fields in the JSON Schema", () => {
const catalog = defineCatalog(testSchema, {
components: {
Form: {
props: z.object({
name: z.string(),
email: z.string(),
nickname: z.string().optional(),
}),
description: "",
slots: [],
},
},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
expect(jsonSchema.Form.required).toEqual(
expect.arrayContaining(["name", "email"]),
);
expect(jsonSchema.Form.required).not.toContain("nickname");
});

it("returns empty object when catalog has no components", () => {
const catalog = defineCatalog(testSchema, {
components: {},
actions: {},
});
const jsonSchema = catalog.jsonSchema() as Record<string, any>;
expect(jsonSchema).toEqual({});
});
});

// =============================================================================
Expand Down
73 changes: 58 additions & 15 deletions packages/core/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,7 +412,18 @@ function createCatalogFromSchema<TDef extends SchemaDefinition, TCatalog>(
},

jsonSchema(): object {
return zodToJsonSchema(zodSchema);
const result: Record<string, object> = {};
if (components) {
for (const [name, entry] of Object.entries(components)) {
const componentEntry = entry as { props?: z.ZodType };
if (componentEntry.props) {
result[name] = zodToJsonSchema(componentEntry.props);
}
}
return result;
} else {
return zodToJsonSchema(zodSchema);
}
},

validate(spec: unknown): SpecValidationResult<InferSpec<TDef, TCatalog>> {
Expand Down Expand Up @@ -1306,39 +1317,66 @@ function formatZodType(schema: z.ZodType): string {
* Convert Zod schema to JSON Schema
*/
function zodToJsonSchema(schema: z.ZodType): object {
// Simplified JSON Schema conversion
const def = schema._def as unknown as Record<string, unknown>;
const typeName = (def.typeName as string) ?? "";
const typeName = getZodTypeName(schema);

switch (typeName) {
case "ZodString":
case "string":
return { type: "string" };
case "ZodNumber":
case "number":
return { type: "number" };
case "ZodBoolean":
case "boolean":
return { type: "boolean" };
case "ZodLiteral":
case "literal":
return { const: def.value };
case "ZodEnum":
return { enum: def.values };
case "ZodArray": {
const inner = def.type as z.ZodType | undefined;
case "enum": {
// Zod 3 uses values array, Zod 4 uses entries object
let values: unknown[];
if (Array.isArray(def.values)) {
values = def.values as unknown[];
} else if (def.entries && typeof def.entries === "object") {
values = Object.values(def.entries as Record<string, unknown>);
} else {
return { type: "string" };
}
return { enum: values };
}
case "ZodArray":
case "array": {
const inner = (
typeof def.element === "object"
? def.element
: typeof def.type === "object"
? def.type
: undefined
) as z.ZodType | undefined;
return {
type: "array",
items: inner ? zodToJsonSchema(inner) : {},
};
}
case "ZodObject": {
const shape = (def.shape as () => Record<string, z.ZodType>)?.();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

zodToJsonSchema for z.literal() reads def.value which is undefined in Zod 4, producing {} instead of { const: "hello" }

Fix on Vercel

case "ZodObject":
case "object": {
const shape =
typeof def.shape === "function"
? (def.shape as () => Record<string, z.ZodType>)()
: (def.shape as Record<string, z.ZodType>);
if (!shape) return { type: "object" };
const properties: Record<string, object> = {};
const required: string[] = [];
for (const [key, value] of Object.entries(shape)) {
properties[key] = zodToJsonSchema(value);
const innerDef = value._def as unknown as Record<string, unknown>;
const innerTypeName = getZodTypeName(value);
if (
innerDef.typeName !== "ZodOptional" &&
innerDef.typeName !== "ZodNullable"
innerTypeName !== "ZodOptional" &&
innerTypeName !== "optional" &&
innerTypeName !== "ZodNullable" &&
innerTypeName !== "nullable"
) {
required.push(key);
}
Expand All @@ -1350,23 +1388,28 @@ function zodToJsonSchema(schema: z.ZodType): object {
additionalProperties: false,
};
}
case "ZodRecord": {
case "ZodRecord":
case "record": {
const valueType = def.valueType as z.ZodType | undefined;
return {
type: "object",
additionalProperties: valueType ? zodToJsonSchema(valueType) : true,
};
}
case "ZodOptional":
case "ZodNullable": {
const inner = def.innerType as z.ZodType | undefined;
case "optional":
case "ZodNullable":
case "nullable": {
const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
return inner ? zodToJsonSchema(inner) : {};
}
case "ZodUnion": {
case "ZodUnion":
case "union": {
const options = def.options as z.ZodType[] | undefined;
return options ? { anyOf: options.map(zodToJsonSchema) } : {};
}
case "ZodAny":
case "any":
return {};
default:
return {};
Expand Down