mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 04:48:17 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e156c935d0 | ||
|
|
26234bbaf8 |
@@ -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);
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.',
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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":[...]}.',
|
||||
|
||||
@@ -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":[...]}.',
|
||||
|
||||
@@ -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":[...]}.',
|
||||
|
||||
@@ -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":[...]}.',
|
||||
|
||||
Reference in New Issue
Block a user