diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 1f57e43d..48a333c5 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -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; + 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; + 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; + 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; + 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; + 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; + expect(jsonSchema).toEqual({}); + }); }); // ============================================================================= diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 0e89fa05..7497d63e 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -412,7 +412,18 @@ function createCatalogFromSchema( }, jsonSchema(): object { - return zodToJsonSchema(zodSchema); + const result: Record = {}; + 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> { @@ -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; - 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); + } 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)?.(); + case "ZodObject": + case "object": { + const shape = + typeof def.shape === "function" + ? (def.shape as () => Record)() + : (def.shape as Record); if (!shape) return { type: "object" }; const properties: Record = {}; const required: string[] = []; for (const [key, value] of Object.entries(shape)) { properties[key] = zodToJsonSchema(value); - const innerDef = value._def as unknown as Record; + const innerTypeName = getZodTypeName(value); if ( - innerDef.typeName !== "ZodOptional" && - innerDef.typeName !== "ZodNullable" + innerTypeName !== "ZodOptional" && + innerTypeName !== "optional" && + innerTypeName !== "ZodNullable" && + innerTypeName !== "nullable" ) { required.push(key); } @@ -1350,7 +1388,8 @@ 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", @@ -1358,15 +1397,19 @@ function zodToJsonSchema(schema: z.ZodType): object { }; } 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 {};