Преглед на файлове

Fix visible validation depending on consumer zod version (#299)

* Fix visible validation depending on consumer zod version; strengthen children prompt rule

catalog.validate() behavior changed under consumers' zod resolution: z.any()
object keys are optional on zod 4.3 but nonoptional on zod 4.4+, so a spec
omitting an element's visible field validates on 4.3 and fails on 4.4. The
core test suite (zod 4.3.6) asserts the optional behavior, so optional is the
intent; pin it explicitly with .optional() so all zod 4.x agree.

children stays required (long-standing, zod-version-independent contract;
relaxing it would change InferSpec types for consumers). Instead the default
prompt rules now state explicitly that every element must include a children
array, with [] for leaves, which benchmarking shows models otherwise omit on
roughly a third of first attempts.

- core: InferSpecObject honors SchemaType.optional at the type level (additive;
  no schema used optional before)
- all framework schemas: visible marked ...s.optional()
- framework prompt defaultRules: REQUIRED FIELDS rule for children
- core: regression tests locking visible-optional and children-required

* Fix Next schema optional fields
Chris Tate преди 1 месец
родител
ревизия
c731a9c607

+ 21 - 1
packages/core/src/schema.test.ts

@@ -14,7 +14,7 @@ const testSchema = defineSchema((s) => ({
         type: s.ref("catalog.components"),
         props: s.propsOf("catalog.components"),
         children: s.array(s.string()),
-        visible: s.any(),
+        visible: { ...s.any(), ...s.optional() },
       }),
     ),
   }),
@@ -583,6 +583,26 @@ describe("catalog.validate", () => {
     expect(result.data).toEqual(spec);
   });
 
+  it("accepts elements without visible regardless of zod version (z.any() keys became nonoptional in zod 4.4)", () => {
+    const result = catalog.validate({
+      root: "text-1",
+      elements: {
+        "text-1": { type: "Text", props: { content: "Hello" }, children: [] },
+      },
+    });
+    expect(result.success).toBe(true);
+  });
+
+  it("still requires children on every element", () => {
+    const result = catalog.validate({
+      root: "text-1",
+      elements: {
+        "text-1": { type: "Text", props: { content: "Hello" } },
+      },
+    });
+    expect(result.success).toBe(false);
+  });
+
   it("rejects spec with wrong root type", () => {
     const result = catalog.validate({ root: 123, elements: {} });
     expect(result.success).toBe(false);

+ 7 - 1
packages/core/src/schema.ts

@@ -328,7 +328,13 @@ export type InferSpec<TDef extends SchemaDefinition, TCatalog> = TDef extends {
   : unknown;
 
 type InferSpecObject<Shape, TCatalog> = {
-  [K in keyof Shape]: InferSpecField<Shape[K], TCatalog>;
+  [K in keyof Shape as Shape[K] extends { optional: true }
+    ? never
+    : K]: InferSpecField<Shape[K], TCatalog>;
+} & {
+  [K in keyof Shape as Shape[K] extends { optional: true }
+    ? K
+    : never]?: InferSpecField<Shape[K], TCatalog>;
 };
 
 type InferSpecField<T, TCatalog> =

+ 2 - 1
packages/image/src/schema.ts

@@ -18,7 +18,7 @@ export const schema = defineSchema(
           type: s.ref("catalog.components"),
           props: s.propsOf("catalog.components"),
           children: s.array(s.string()),
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -41,6 +41,7 @@ export const schema = defineSchema(
       "Image src must be a fully qualified URL. For placeholder images, use https://picsum.photos/{width}/{height}?random={n}.",
       "Satori renders a subset of CSS: flexbox layout, borders, backgrounds, text styling. Absolute positioning is supported via position/top/left/right/bottom.",
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
     ],
   },
 );

+ 2 - 1
packages/ink/src/schema.ts

@@ -22,7 +22,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -70,6 +70,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
       'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',

+ 104 - 0
packages/next/src/schema.test.ts

@@ -0,0 +1,104 @@
+import { describe, expect, it } from "vitest";
+import { schema, type NextSpec } from "./schema";
+
+const catalog = schema.createCatalog({
+  components: {},
+  actions: {},
+});
+
+describe("@json-render/next schema", () => {
+  it("accepts the minimal public Next app spec shape", () => {
+    const spec = {
+      routes: {
+        "/": {
+          page: {
+            root: "root",
+            elements: {
+              root: {
+                type: "Card",
+                props: {},
+                children: [],
+              },
+            },
+          },
+        },
+      },
+    };
+
+    const result = catalog.validate(spec);
+    expect(result.success).toBe(true);
+    expect(result.data).toEqual(spec);
+  });
+
+  it("preserves optional metadata fields during validation", () => {
+    const spec = {
+      metadata: {
+        title: "Home",
+        alternates: {
+          canonical: "https://example.com",
+        },
+      },
+      routes: {
+        "/": {
+          page: {
+            root: "root",
+            elements: {
+              root: {
+                type: "Card",
+                props: {},
+                children: [],
+              },
+            },
+          },
+        },
+      },
+    };
+
+    const result = catalog.validate(spec);
+    expect(result.success).toBe(true);
+    expect(result.data).toEqual(spec);
+  });
+
+  it("still requires children on every page element", () => {
+    const result = catalog.validate({
+      routes: {
+        "/": {
+          page: {
+            root: "root",
+            elements: {
+              root: {
+                type: "Card",
+                props: {},
+              },
+            },
+          },
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  it("infers documented Next fields as optional", () => {
+    type InferredSpec = NextSpec<Parameters<typeof schema.createCatalog>[0]>;
+
+    const spec: InferredSpec = {
+      routes: {
+        "/": {
+          page: {
+            root: "root",
+            elements: {
+              root: {
+                type: "Card",
+                props: {},
+                children: [],
+              },
+            },
+          },
+        },
+      },
+    };
+
+    expect(spec.routes["/"]?.page.root).toBe("root");
+  });
+});

+ 40 - 33
packages/next/src/schema.ts

@@ -212,15 +212,19 @@ export const schema = defineSchema(
   (s) => ({
     spec: s.object({
       /** Root-level metadata applied as defaults */
-      metadata: s.object({
-        title: s.any(),
-        description: s.string(),
-        keywords: s.array(s.string()),
-        openGraph: s.any(),
-        twitter: s.any(),
-        robots: s.any(),
-        icons: s.any(),
-      }),
+      metadata: {
+        ...s.object({
+          title: { ...s.any(), ...s.optional() },
+          description: { ...s.string(), ...s.optional() },
+          keywords: { ...s.array(s.string()), ...s.optional() },
+          openGraph: { ...s.any(), ...s.optional() },
+          twitter: { ...s.any(), ...s.optional() },
+          robots: { ...s.any(), ...s.optional() },
+          alternates: { ...s.any(), ...s.optional() },
+          icons: { ...s.any(), ...s.optional() },
+        }),
+        ...s.optional(),
+      },
 
       /** Route definitions keyed by URL pattern */
       routes: s.record(
@@ -233,46 +237,49 @@ export const schema = defineSchema(
                 type: s.ref("catalog.components"),
                 props: s.propsOf("catalog.components"),
                 children: s.array(s.string()),
-                visible: s.any(),
+                visible: { ...s.any(), ...s.optional() },
               }),
             ),
-            state: s.any(),
+            state: { ...s.any(), ...s.optional() },
           }),
           /** Per-route metadata */
-          metadata: s.any(),
+          metadata: { ...s.any(), ...s.optional() },
           /** Layout key */
-          layout: s.string(),
+          layout: { ...s.string(), ...s.optional() },
           /** Loading state element tree */
-          loading: s.any(),
+          loading: { ...s.any(), ...s.optional() },
           /** Error state element tree */
-          error: s.any(),
+          error: { ...s.any(), ...s.optional() },
           /** Not-found element tree */
-          notFound: s.any(),
+          notFound: { ...s.any(), ...s.optional() },
           /** Server-side data loader name */
-          loader: s.string(),
+          loader: { ...s.string(), ...s.optional() },
           /** Static params for SSG */
-          staticParams: s.any(),
+          staticParams: { ...s.any(), ...s.optional() },
         }),
       ),
 
       /** Reusable layout element trees */
-      layouts: s.record(
-        s.object({
-          root: s.string(),
-          elements: s.record(
-            s.object({
-              type: s.ref("catalog.components"),
-              props: s.propsOf("catalog.components"),
-              children: s.array(s.string()),
-              visible: s.any(),
-            }),
-          ),
-          state: s.any(),
-        }),
-      ),
+      layouts: {
+        ...s.record(
+          s.object({
+            root: s.string(),
+            elements: s.record(
+              s.object({
+                type: s.ref("catalog.components"),
+                props: s.propsOf("catalog.components"),
+                children: s.array(s.string()),
+                visible: { ...s.any(), ...s.optional() },
+              }),
+            ),
+            state: { ...s.any(), ...s.optional() },
+          }),
+        ),
+        ...s.optional(),
+      },
 
       /** Global initial state */
-      state: s.any(),
+      state: { ...s.any(), ...s.optional() },
     }),
 
     catalog: s.object({

+ 2 - 1
packages/react-email/src/schema.ts

@@ -18,7 +18,7 @@ export const schema = defineSchema(
           type: s.ref("catalog.components"),
           props: s.propsOf("catalog.components"),
           children: s.array(s.string()),
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -44,6 +44,7 @@ export const schema = defineSchema(
       "Use Heading (h1-h6) and Text for all text content. Raw strings are not supported.",
       "Button renders as a link styled as a button. Always provide both text and href.",
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
     ],
   },
 );

+ 2 - 1
packages/react-native/src/schema.ts

@@ -23,7 +23,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -59,6 +59,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
       'When building repeating content backed by a state array (e.g. todos, posts, cart items), use the "repeat" field on a container element from the AVAILABLE COMPONENTS list. Example: { "type": "<ContainerComponent>", "props": { "gap": 8 }, "repeat": { "statePath": "/todos", "key": "id" }, "children": ["todo-item"] }. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
 
       // Visible field placement

+ 2 - 1
packages/react-pdf/src/schema.ts

@@ -18,7 +18,7 @@ export const schema = defineSchema(
           type: s.ref("catalog.components"),
           props: s.propsOf("catalog.components"),
           children: s.array(s.string()),
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -44,6 +44,7 @@ export const schema = defineSchema(
       "NEVER use emoji characters in any text content. The PDF font (Helvetica) does not support emojis and they will render as garbled/overlapping characters. Use plain text descriptions instead (e.g. 'Phone:' not '📞', 'Email:' not '📧').",
       "PAGE LAYOUT: Be conservative with content density. A portrait A4/LETTER page with 40pt margins fits roughly 700pt of content height. For single-page documents (flyers, posters, one-pagers), keep all content on one page using smaller font sizes (10-11), tighter gaps (4-8), less padding (10-15), and smaller images (max height 200). For multi-page documents (resumes, reports), pack content densely to avoid large blank areas at the bottom of pages. Use small margins (marginTop: 30, marginBottom: 20), tight spacing (gap: 4-6), and compact font sizes (9-11 for body text) so pages are well-filled. It is better to fit more content on fewer pages than to spread thin content across many pages.",
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
     ],
   },
 );

+ 2 - 1
packages/react/src/schema.ts

@@ -23,7 +23,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -77,6 +77,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
 
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',

+ 2 - 1
packages/solid/src/schema.ts

@@ -23,7 +23,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -77,6 +77,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
 
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',

+ 2 - 1
packages/svelte/src/schema.ts

@@ -23,7 +23,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -77,6 +77,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
 
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',

+ 2 - 1
packages/vue/src/schema.ts

@@ -23,7 +23,7 @@ export const schema = defineSchema(
           /** Child element keys (flat reference) */
           children: s.array(s.string()),
           /** Visibility condition */
-          visible: s.any(),
+          visible: { ...s.any(), ...s.optional() },
         }),
       ),
     }),
@@ -77,6 +77,7 @@ export const schema = defineSchema(
       // Element integrity
       "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
       "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
+      'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
 
       // Field placement
       'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',