Compare commits

...
Author SHA1 Message Date
Chris Tate e156c935d0 Fix Next schema optional fields 2026-06-10 14:39:10 -05:00
Chris Tate 26234bbaf8 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
2026-06-10 13:59:00 -05:00
13 changed files with 190 additions and 44 deletions
+21 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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":[...]}.',