From 0f6798b1937bf8f4371fc38a83de95dbf7c6a0fd Mon Sep 17 00:00:00 2001 From: Railly Hugo <51397083+Railly@users.noreply.github.com> Date: Fri, 14 Aug 2026 22:22:49 -0300 Subject: [PATCH] feat(react): support named slots (#320) Co-authored-by: wotnak --- apps/web/app/(main)/docs/api/core/page.mdx | 16 ++- apps/web/app/(main)/docs/catalog/page.mdx | 40 ++++-- apps/web/app/(main)/docs/registry/page.mdx | 95 +++++++------ apps/web/app/(main)/docs/specs/page.mdx | 68 ++++++--- apps/web/components/demo.tsx | 47 ++++++- apps/web/components/playground.tsx | 47 ++++++- apps/web/lib/spec-patch.ts | 4 + packages/codegen/src/traverse.test.ts | 27 ++++ packages/codegen/src/traverse.ts | 8 ++ packages/core/README.md | 4 +- packages/core/src/schema.test.ts | 5 +- packages/core/src/schema.ts | 35 ++++- packages/core/src/spec-validator.test.ts | 89 ++++++++++++ packages/core/src/spec-validator.ts | 59 ++++++++ packages/core/src/types.test.ts | 21 +++ packages/core/src/types.ts | 23 ++- packages/devtools/src/panel/tabs/spec.ts | 34 +++-- packages/react/README.md | 154 ++++++++++++--------- packages/react/src/catalog-types.ts | 1 + packages/react/src/hooks.test.ts | 25 ++++ packages/react/src/renderer.test.tsx | 60 +++++++- packages/react/src/renderer.tsx | 84 ++++++++--- packages/react/src/schema.ts | 2 + skills/react/SKILL.md | 88 ++++++++---- 24 files changed, 807 insertions(+), 229 deletions(-) diff --git a/apps/web/app/(main)/docs/api/core/page.mdx b/apps/web/app/(main)/docs/api/core/page.mdx index ed04bbc..020a014 100644 --- a/apps/web/app/(main)/docs/api/core/page.mdx +++ b/apps/web/app/(main)/docs/api/core/page.mdx @@ -339,17 +339,18 @@ setSpec({ ...applySpecPatch(spec, patch) }); ### nestedToFlat -Convert a nested element tree (with inline children) into the flat `Spec` format: +Convert a nested element tree (with inline children and named slots) into the flat `Spec` format: ```typescript import { nestedToFlat } from '@json-render/core'; const flat = nestedToFlat({ - type: "Card", - props: { title: "Hello" }, - children: [ - { type: "Text", props: { content: "World" }, children: [] } - ], + type: "Layout", + props: {}, + children: [{ type: "Text", props: { content: "Main" }, children: [] }], + slots: { + header: [{ type: "Heading", props: { text: "Header" }, children: [] }], + }, }); // { root: "el-0", elements: { "el-0": ..., "el-1": ... } } ``` @@ -648,6 +649,7 @@ interface UIElement { type: string; props: Record; children?: string[]; // Keys of child elements + slots?: Record; // Named slots mapped to child keys visible?: VisibilityCondition; on?: Record; // Event bindings repeat?: { statePath: string | { $item: string }; key?: string }; // Repeat for arrays @@ -666,7 +668,7 @@ interface Spec { } ``` -Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays. +Elements are stored as a flat map with string keys. The tree structure is built by following `children` and named `slots` references. ### ActionBinding diff --git a/apps/web/app/(main)/docs/catalog/page.mdx b/apps/web/app/(main)/docs/catalog/page.mdx index c3a1782..8f6bae5 100644 --- a/apps/web/app/(main)/docs/catalog/page.mdx +++ b/apps/web/app/(main)/docs/catalog/page.mdx @@ -1,5 +1,5 @@ -import { pageMetadata } from "@/lib/page-metadata" -export const metadata = pageMetadata("docs/catalog") +import { pageMetadata } from "@/lib/page-metadata"; +export const metadata = pageMetadata("docs/catalog"); # Catalog @@ -18,9 +18,9 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin `defineCatalog` is from `@json-render/core`. The `schema` import comes from your platform package (`@json-render/react` or `@json-render/react-native`) and defines the element structure the catalog targets. The catalog definition itself is framework-agnostic. ```typescript -import { defineCatalog } from '@json-render/core'; -import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema' -import { z } from 'zod'; +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/react/schema"; // or '@json-render/react-native/schema' +import { z } from "zod"; const catalog = defineCatalog(schema, { components: { @@ -29,35 +29,35 @@ const catalog = defineCatalog(schema, { props: z.object({ title: z.string(), description: z.string().nullable(), - padding: z.enum(['sm', 'md', 'lg']).nullable(), + padding: z.enum(["sm", "md", "lg"]).nullable(), }), slots: ["default"], // Can contain other components description: "Container card for grouping content", }, - + Metric: { props: z.object({ label: z.string(), value: z.union([z.string(), z.number()]), - format: z.enum(['currency', 'percent', 'number']), + format: z.enum(["currency", "percent", "number"]), }), description: "Display a single metric value", }, }, - + actions: { submit_form: { params: z.object({ formId: z.string(), }), - description: 'Submit a form', + description: "Submit a form", }, - + export_data: { params: z.object({ - format: z.enum(['csv', 'pdf', 'json']), + format: z.enum(["csv", "pdf", "json"]), }), - description: 'Export data in various formats', + description: "Export data in various formats", }, }, }); @@ -70,12 +70,22 @@ Each component in the catalog has: ```typescript { props: z.object({...}), // Zod schema for props (use .nullable() for optional) - slots?: string[], // Named slots for children (e.g., ["default"]) + slots?: string[], // Available slots (e.g., ["default", "header", "footer"]) description?: string, // Help AI understand when to use it } ``` -Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered. +Use `"default"` for regular children. Add named slots when a component places content in multiple regions: + +```typescript +Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + description: "Page layout with header, content, and footer regions", +} +``` + +React specs use `children` for the default slot and a `slots` object for the other names. ## Generating AI Prompts diff --git a/apps/web/app/(main)/docs/registry/page.mdx b/apps/web/app/(main)/docs/registry/page.mdx index 1adb0af..20ac5c7 100644 --- a/apps/web/app/(main)/docs/registry/page.mdx +++ b/apps/web/app/(main)/docs/registry/page.mdx @@ -1,9 +1,9 @@ -import { pageMetadata } from "@/lib/page-metadata" -export const metadata = pageMetadata("docs/registry") +import { pageMetadata } from "@/lib/page-metadata"; +export const metadata = pageMetadata("docs/registry"); # Registry -A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*. +A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines _what_ AI can generate; the registry provides the _how_. What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry. @@ -19,8 +19,8 @@ What a registry contains depends on the schema you use. Each package defines its Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both: ```tsx -import { defineRegistry } from '@json-render/react'; -import { myCatalog } from './catalog'; +import { defineRegistry } from "@json-render/react"; +import { myCatalog } from "./catalog"; export const { registry, handlers, executeAction } = defineRegistry(myCatalog, { components: { @@ -33,16 +33,14 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, { ), Button: ({ props, emit }) => ( - + ), }, actions: { submit_form: async (params, setState) => { - const res = await fetch('/api/submit', { - method: 'POST', + const res = await fetch("/api/submit", { + method: "POST", body: JSON.stringify(params), }); const result = await res.json(); @@ -69,23 +67,36 @@ Each component receives a `ComponentContext` object: ```typescript interface ComponentContext { - props: T; // Type-safe props from your catalog - children?: React.ReactNode; // Rendered children (for slot components) - emit: (event: string) => void; // Emit a named event (always defined) - on: (event: string) => EventHandle; // Get event handle with metadata - loading?: boolean; // Whether the renderer is in a loading state - bindings?: Record; // State paths from $bindState/$bindItem expressions + props: T; // Type-safe props from your catalog + children?: React.ReactNode; // Rendered children (for slot components) + slots?: Record; // Rendered named slots + emit: (event: string) => void; // Emit a named event (always defined) + on: (event: string) => EventHandle; // Get event handle with metadata + loading?: boolean; // Whether the renderer is in a loading state + bindings?: Record; // State paths from $bindState/$bindItem expressions } interface EventHandle { - emit: () => void; // Fire the event + emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault - bound: boolean; // Whether any handler is bound + bound: boolean; // Whether any handler is bound } ``` Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way. +For components with named slots, read the default content from `children` and other regions from `slots`: + +```tsx +Layout: ({ children, slots }) => ( +
+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+), +``` + Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata: ```tsx @@ -128,27 +139,29 @@ TextInput: ({ props, bindings }) => { ### Action Handlers -Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail. +Instead of AI generating arbitrary code, it declares _intent_ by name. Your application provides the implementation. This is a core guardrail. Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger: ```typescript -import { defineCatalog } from '@json-render/core'; -import { schema } from '@json-render/react/schema'; -import { z } from 'zod'; +import { defineCatalog } from "@json-render/core"; +import { schema } from "@json-render/react/schema"; +import { z } from "zod"; const catalog = defineCatalog(schema, { - components: { /* ... */ }, + components: { + /* ... */ + }, actions: { submit_form: { params: z.object({ formId: z.string(), }), - description: 'Submit a form', + description: "Submit a form", }, export_data: { params: z.object({ - format: z.enum(['csv', 'pdf', 'json']), + format: z.enum(["csv", "pdf", "json"]), }), }, navigate: { @@ -166,8 +179,8 @@ Action handlers receive `(params, setState, state)` and are defined inside `defi export const { handlers, executeAction } = defineRegistry(catalog, { actions: { submit_form: async (params, setState) => { - const response = await fetch('/api/submit', { - method: 'POST', + const response = await fetch("/api/submit", { + method: "POST", body: JSON.stringify({ formId: params.formId }), }); const result = await response.json(); @@ -219,14 +232,14 @@ For read-only state access (e.g. displaying a value from state), use `$state` ex Wire everything together with providers and the `` component: ```tsx -import { useMemo, useRef } from 'react'; +import { useMemo, useRef } from "react"; import { Renderer, StateProvider, VisibilityProvider, ActionProvider, -} from '@json-render/react'; -import { registry, handlers } from './registry'; +} from "@json-render/react"; +import { registry, handlers } from "./registry"; function App({ spec, state, setState }) { const stateRef = useRef(state); @@ -235,7 +248,11 @@ function App({ spec, state, setState }) { setStateRef.current = setState; const actionHandlers = useMemo( - () => handlers(() => setStateRef.current, () => stateRef.current), + () => + handlers( + () => setStateRef.current, + () => stateRef.current, + ), [], ); @@ -256,8 +273,8 @@ function App({ spec, state, setState }) { `@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML: ```tsx -import { defineRegistry } from '@json-render/react-native'; -import { View, Text, Pressable } from 'react-native'; +import { defineRegistry } from "@json-render/react-native"; +import { View, Text, Pressable } from "react-native"; export const { registry } = defineRegistry(catalog, { components: { @@ -284,14 +301,14 @@ See the [@json-render/react-native API reference](/docs/api/react-native) for th `@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output: ```tsx -import { defineRegistry } from '@json-render/react-email'; -import { renderToHtml } from '@json-render/react-email'; -import { Body, Container, Heading, Text } from '@react-email/components'; +import { defineRegistry } from "@json-render/react-email"; +import { renderToHtml } from "@json-render/react-email"; +import { Body, Container, Heading, Text } from "@react-email/components"; export const { registry } = defineRegistry(catalog, { components: { Card: ({ props, children }) => ( - + {props.title} {children} @@ -309,10 +326,10 @@ See the [@json-render/react-email API reference](/docs/api/react-email) for the `@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production: ```tsx -import { Renderer, standardComponents } from '@json-render/remotion'; +import { Renderer, standardComponents } from "@json-render/remotion"; // Use the standard components directly - +; // Or extend with your own const components = { diff --git a/apps/web/app/(main)/docs/specs/page.mdx b/apps/web/app/(main)/docs/specs/page.mdx index 4eaff49..03a1afe 100644 --- a/apps/web/app/(main)/docs/specs/page.mdx +++ b/apps/web/app/(main)/docs/specs/page.mdx @@ -1,5 +1,5 @@ -import { pageMetadata } from "@/lib/page-metadata" -export const metadata = pageMetadata("docs/specs") +import { pageMetadata } from "@/lib/page-metadata"; +export const metadata = pageMetadata("docs/specs"); # Specs @@ -60,7 +60,10 @@ A more complex spec with multiple nested elements: }, "avatar-1": { "type": "Avatar", - "props": { "src": { "$state": "/user/avatar" }, "alt": { "$state": "/user/name" } }, + "props": { + "src": { "$state": "/user/avatar" }, + "alt": { "$state": "/user/name" } + }, "children": [] }, "stack-1": { @@ -102,7 +105,10 @@ A high-level spec using semantic blocks for page layouts: }, "header": { "type": "Header", - "props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] }, + "props": { + "logo": "/logo.svg", + "navItems": ["Products", "Pricing", "Docs"] + }, "children": [] }, "hero": { @@ -122,22 +128,37 @@ A high-level spec using semantic blocks for page layouts: }, "feature-1": { "type": "Feature", - "props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" }, + "props": { + "icon": "zap", + "title": "Fast", + "description": "Render UIs in milliseconds" + }, "children": [] }, "feature-2": { "type": "Feature", - "props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" }, + "props": { + "icon": "shield", + "title": "Secure", + "description": "Validate all specs against your catalog" + }, "children": [] }, "feature-3": { "type": "Feature", - "props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" }, + "props": { + "icon": "sparkles", + "title": "AI-Ready", + "description": "Generate prompts from your catalog" + }, "children": [] }, "footer": { "type": "Footer", - "props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] }, + "props": { + "copyright": "2025 Acme Inc", + "links": ["Privacy", "Terms", "Contact"] + }, "children": [] } } @@ -174,13 +195,18 @@ Each element in the map has a consistent shape: { "type": "ComponentName", "props": { "label": "Hello" }, - "children": ["child-1", "child-2"] + "children": ["child-1", "child-2"], + "slots": { + "header": ["heading-1"], + "footer": ["actions-1"] + } } ``` - `type` — Component type from your catalog - `props` — Component properties - `children` — Array of child element keys +- `slots`: Optional map of named slots to child element keys. Use `children` for the default slot. Named slot rendering is currently supported by `@json-render/react`. ### Dynamic Data @@ -225,12 +251,12 @@ Control when elements appear using the `visible` property: Use `validateSpec` from `@json-render/core` to check a spec for structural issues: ```typescript -import { validateSpec } from '@json-render/core'; +import { validateSpec } from "@json-render/core"; const result = validateSpec(spec); if (!result.valid) { - console.error('Invalid spec:', result.issues); + console.error("Invalid spec:", result.issues); } ``` @@ -239,8 +265,12 @@ if (!result.valid) { With `@json-render/react`, wrap the `Renderer` in providers to supply state and visibility: ```tsx -import { Renderer, StateProvider, VisibilityProvider } from '@json-render/react'; -import { registry } from './registry'; +import { + Renderer, + StateProvider, + VisibilityProvider, +} from "@json-render/react"; +import { registry } from "./registry"; function MyApp({ spec, initialState }) { return ( @@ -260,20 +290,14 @@ See the [@json-render/react API reference](/docs/api/react) for full provider an With `@json-render/react`, use the `useUIStream` hook to stream specs incrementally: ```tsx -import { useUIStream } from '@json-render/react'; +import { useUIStream } from "@json-render/react"; function GenerativeUI() { const { spec, isStreaming } = useUIStream({ - api: '/api/generate', + api: "/api/generate", }); - return ( - - ); + return ; } ``` diff --git a/apps/web/components/demo.tsx b/apps/web/components/demo.tsx index fc8f896..8a28952 100644 --- a/apps/web/components/demo.tsx +++ b/apps/web/components/demo.tsx @@ -195,6 +195,15 @@ function specToNested(spec: Spec): Record { node.children = el.children.map(resolve); } + if (el.slots && Object.keys(el.slots).length > 0) { + node.slots = Object.fromEntries( + Object.entries(el.slots).map(([slotName, childKeys]) => [ + slotName, + childKeys.map(resolve), + ]), + ); + } + return node; } @@ -393,21 +402,45 @@ export function Demo({ const propsStr = serializeProps(propsObj); const hasChildren = element.children && element.children.length > 0; + const hasSlots = element.slots && Object.keys(element.slots).length > 0; - if (!hasChildren) { + if (!hasChildren && !hasSlots) { return propsStr ? `${spaces}<${componentName} ${propsStr} />` : `${spaces}<${componentName} />`; } const lines: string[] = []; - lines.push( - propsStr - ? `${spaces}<${componentName} ${propsStr}>` - : `${spaces}<${componentName}>`, - ); + if (hasSlots) { + lines.push(`${spaces}<${componentName}`); + if (propsStr) { + lines.push(`${spaces} ${propsStr}`); + } + for (const [slotName, childKeys] of Object.entries(element.slots!)) { + const slotChildren = childKeys + .map((childKey) => generateJSX(childKey, indent + 2)) + .filter(Boolean); + if (slotChildren.length === 0) continue; + lines.push(`${spaces} ${slotName}={`); + if (slotChildren.length > 1) { + lines.push(`${spaces} <>`); + } + lines.push(...slotChildren); + if (slotChildren.length > 1) { + lines.push(`${spaces} `); + } + lines.push(`${spaces} }`); + } + lines.push(`${spaces}>`); + } else { + lines.push( + propsStr + ? `${spaces}<${componentName} ${propsStr}>` + : `${spaces}<${componentName}>`, + ); + } - for (const childKey of element.children!) { + for (const childKey of element.children ?? []) { lines.push(generateJSX(childKey, indent + 1)); } diff --git a/apps/web/components/playground.tsx b/apps/web/components/playground.tsx index e98261b..f971fe5 100644 --- a/apps/web/components/playground.tsx +++ b/apps/web/components/playground.tsx @@ -152,6 +152,15 @@ function specToNested(spec: Spec): Record { node.children = el.children.map(resolve); } + if (el.slots && Object.keys(el.slots).length > 0) { + node.slots = Object.fromEntries( + Object.entries(el.slots).map(([slotName, childKeys]) => [ + slotName, + childKeys.map(resolve), + ]), + ); + } + return node; } @@ -373,21 +382,45 @@ export function Playground() { const propsStr = serializeProps(propsObj); const hasChildren = element.children && element.children.length > 0; + const hasSlots = element.slots && Object.keys(element.slots).length > 0; - if (!hasChildren) { + if (!hasChildren && !hasSlots) { return propsStr ? `${spaces}<${componentName} ${propsStr} />` : `${spaces}<${componentName} />`; } const lines: string[] = []; - lines.push( - propsStr - ? `${spaces}<${componentName} ${propsStr}>` - : `${spaces}<${componentName}>`, - ); + if (hasSlots) { + lines.push(`${spaces}<${componentName}`); + if (propsStr) { + lines.push(`${spaces} ${propsStr}`); + } + for (const [slotName, childKeys] of Object.entries(element.slots!)) { + const slotChildren = childKeys + .map((childKey) => generateJSX(childKey, indent + 2)) + .filter(Boolean); + if (slotChildren.length === 0) continue; + lines.push(`${spaces} ${slotName}={`); + if (slotChildren.length > 1) { + lines.push(`${spaces} <>`); + } + lines.push(...slotChildren); + if (slotChildren.length > 1) { + lines.push(`${spaces} `); + } + lines.push(`${spaces} }`); + } + lines.push(`${spaces}>`); + } else { + lines.push( + propsStr + ? `${spaces}<${componentName} ${propsStr}>` + : `${spaces}<${componentName}>`, + ); + } - for (const childKey of element.children!) { + for (const childKey of element.children ?? []) { lines.push(generateJSX(childKey, indent + 1)); } diff --git a/apps/web/lib/spec-patch.ts b/apps/web/lib/spec-patch.ts index cfff776..8a26f93 100644 --- a/apps/web/lib/spec-patch.ts +++ b/apps/web/lib/spec-patch.ts @@ -35,6 +35,10 @@ export function setSpecValue( type: typeof el.type === "string" ? el.type : "", props: el.props != null && typeof el.props === "object" ? el.props : {}, children: Array.isArray(el.children) ? el.children : [], + slots: + el.slots != null && typeof el.slots === "object" + ? el.slots + : undefined, } as Spec["elements"][string]; } else { const element = newSpec.elements[elementKey]; diff --git a/packages/codegen/src/traverse.test.ts b/packages/codegen/src/traverse.test.ts index 820fa44..06cc788 100644 --- a/packages/codegen/src/traverse.test.ts +++ b/packages/codegen/src/traverse.test.ts @@ -43,6 +43,33 @@ describe("traverseSpec", () => { }); expect(visited).toEqual([]); }); + + it("visits named slot children depth-first", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Layout", + props: {}, + children: ["main"], + slots: { + header: ["heading"], + footer: ["actions"], + }, + }, + main: { type: "Content", props: {} }, + heading: { type: "Heading", props: {} }, + actions: { type: "Actions", props: {} }, + }, + }; + + const visited: string[] = []; + traverseSpec(spec, (_element, key) => { + visited.push(key); + }); + + expect(visited).toEqual(["root", "main", "heading", "actions"]); + }); }); describe("collectUsedComponents", () => { diff --git a/packages/codegen/src/traverse.ts b/packages/codegen/src/traverse.ts index a65c256..55ed0b7 100644 --- a/packages/codegen/src/traverse.ts +++ b/packages/codegen/src/traverse.ts @@ -37,6 +37,14 @@ export function traverseSpec( visit(childKey, depth + 1, element); } } + + if (element.slots) { + for (const childKeys of Object.values(element.slots)) { + for (const childKey of childKeys) { + visit(childKey, depth + 1, element); + } + } + } } visit(rootKey, 0, null); diff --git a/packages/core/README.md b/packages/core/README.md index e11866e..3e40a08 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -556,9 +556,9 @@ console.log(formatSpecIssues(issues)); const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec); ``` -`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`). +`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` and named `slots` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), relative repeat paths outside an enclosing repeat (`repeat_item_outside_scope`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`). -`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted: +`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` or named `slots` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted: ```typescript const lastAttempt = retriesUsed >= maxRetries; diff --git a/packages/core/src/schema.test.ts b/packages/core/src/schema.test.ts index 4b8a6e0..9629de1 100644 --- a/packages/core/src/schema.test.ts +++ b/packages/core/src/schema.test.ts @@ -14,6 +14,7 @@ const testSchema = defineSchema((s) => ({ type: s.ref("catalog.components"), props: s.propsOf("catalog.components"), children: s.array(s.string()), + slots: { ...s.record(s.array(s.string())), ...s.optional() }, visible: { ...s.any(), ...s.optional() }, }), ), @@ -175,7 +176,7 @@ describe("catalog.prompt", () => { users: z.array(z.object({ name: z.string(), age: z.number() })), }), description: "A card container", - slots: ["default"], + slots: ["default", "header"], }, }, actions: {}, @@ -187,6 +188,8 @@ describe("catalog.prompt", () => { expect(prompt).toContain("title: string"); expect(prompt).toContain("names: Array"); expect(prompt).toContain("users: Array<{ name: string, age: number }>"); + expect(prompt).toContain("[accepts children; slots: header]"); + expect(prompt).not.toContain("slots: default"); }); it("formats z.literal() as quoted value", () => { diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 1e36bff..e5aa0bf 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -660,6 +660,21 @@ function generatePrompt( const allComponents = (catalog.data as Record).components as | Record | undefined; + const specDefinition = catalog.schema.definition.spec; + const specShape = + specDefinition.kind === "object" + ? (specDefinition.inner as Record) + : undefined; + const elementsDefinition = specShape?.elements; + const elementDefinition = + elementsDefinition?.kind === "record" + ? (elementsDefinition.inner as SchemaType) + : undefined; + const elementShape = + elementDefinition?.kind === "object" + ? (elementDefinition.inner as Record) + : undefined; + const supportsNamedSlots = elementShape?.slots !== undefined; const cn = catalog.componentNames; const comp1 = cn[0] || "Component"; const comp2 = cn.length > 1 ? cn[1]! : comp1; @@ -812,14 +827,28 @@ Note: state patches appear right after the elements that use them, so the UI fil for (const [name, def] of Object.entries(components)) { const propsStr = def.props ? formatZodType(def.props) : "{}"; - const hasChildren = def.slots && def.slots.length > 0; - const childrenStr = hasChildren ? " [accepts children]" : ""; + const slotNames = def.slots ?? []; + const namedSlotNames = slotNames.filter((slot) => slot !== "default"); + const acceptsChildren = slotNames.includes("default"); + const slotsStr = supportsNamedSlots + ? [ + acceptsChildren ? "accepts children" : "", + namedSlotNames.length > 0 + ? `slots: ${namedSlotNames.join(", ")}` + : "", + ] + .filter(Boolean) + .join("; ") + : slotNames.length > 0 + ? "accepts children" + : ""; + const slotsSuffix = slotsStr ? ` [${slotsStr}]` : ""; const eventsStr = def.events && def.events.length > 0 ? ` [events: ${def.events.join(", ")}]` : ""; const descStr = def.description ? ` - ${def.description}` : ""; - lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`); + lines.push(`- ${name}: ${propsStr}${descStr}${slotsSuffix}${eventsStr}`); } lines.push(""); } diff --git a/packages/core/src/spec-validator.test.ts b/packages/core/src/spec-validator.test.ts index af312b0..71853bf 100644 --- a/packages/core/src/spec-validator.test.ts +++ b/packages/core/src/spec-validator.test.ts @@ -59,6 +59,28 @@ describe("validateSpec", () => { expect(result.issues.some((i) => i.code === "missing_child")).toBe(true); }); + it("detects missing children in named slots", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Layout", + props: {}, + slots: { header: ["nonexistent"] }, + }, + }, + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.issues).toContainEqual( + expect.objectContaining({ + code: "missing_child", + elementKey: "root", + message: expect.stringContaining('slot "header"'), + }), + ); + }); + it("detects visible_in_props", () => { const spec: Spec = { root: "root", @@ -141,6 +163,23 @@ describe("validateSpec", () => { expect(result.valid).toBe(true); expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true); }); + + it("treats named slot children as reachable", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Layout", + props: {}, + slots: { header: ["heading"] }, + }, + heading: { type: "Heading", props: {} }, + }, + }; + const result = validateSpec(spec, { checkOrphans: true }); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); }); // ============================================================================= @@ -285,6 +324,34 @@ describe("repeat validation", () => { ).toBe(true); }); + it("does not give named slots the repeat scope created by their element", () => { + const result = validateSpec({ + root: "items", + state: { items: [{ nested: [] }] }, + elements: { + items: { + type: "Layout", + props: {}, + repeat: { statePath: "/items" }, + children: ["body"], + slots: { header: ["nested"] }, + }, + body: { type: "Text", props: {}, children: [] }, + nested: { + type: "Stack", + props: {}, + repeat: { statePath: { $item: "nested" } }, + children: ["label"], + }, + label: { type: "Text", props: {}, children: [] }, + }, + }); + + expect( + result.issues.some((issue) => issue.code === "repeat_item_outside_scope"), + ).toBe(true); + }); + it("accepts relative repeat structure when the outer sample array is empty", () => { const result = validateSpec({ root: "groups", @@ -519,6 +586,28 @@ describe("autoFixSpec", () => { expect(fixes).toEqual([]); }); + it("prunes undefined children from named slots", () => { + const spec: Spec = { + root: "root", + elements: { + root: { + type: "Layout", + props: {}, + slots: { header: ["heading", "ghost"] }, + }, + heading: { type: "Heading", props: {} }, + }, + }; + const { spec: fixed, fixDetails } = autoFixSpec(spec); + expect(fixed.elements.root!.slots).toEqual({ header: ["heading"] }); + expect(fixDetails).toContainEqual({ + message: + 'Removed reference to undefined element "ghost" from slot "header" of "root".', + lossy: true, + }); + expect(validateSpec(fixed).valid).toBe(true); + }); + it("does not prune a repeat container down to zero children", () => { const spec: Spec = { root: "list", diff --git a/packages/core/src/spec-validator.ts b/packages/core/src/spec-validator.ts index b0f2c76..b9fa53e 100644 --- a/packages/core/src/spec-validator.ts +++ b/packages/core/src/spec-validator.ts @@ -131,6 +131,20 @@ export function validateSpec( } } } + if (element.slots) { + for (const [slotName, childKeys] of Object.entries(element.slots)) { + for (const childKey of childKeys) { + if (!spec.elements[childKey]) { + issues.push({ + severity: "error", + message: `Element "${key}" references child "${childKey}" in slot "${slotName}" which does not exist in the elements map.`, + elementKey: key, + code: "missing_child", + }); + } + } + } + } // 3b. Repeat containers that can never render anything. Both shapes pass // schema validation but produce silently empty regions at runtime. @@ -272,6 +286,16 @@ export function validateSpec( nextAncestors, ); } + for (const childKeys of Object.values(element.slots ?? {})) { + for (const childKey of childKeys) { + validateRepeatPaths( + childKey, + repeatBasePath, + sampleAvailable, + nextAncestors, + ); + } + } }; if (spec.elements[spec.root]) { @@ -297,6 +321,15 @@ export function validateSpec( } } } + if (el?.slots) { + for (const childKeys of Object.values(el.slots)) { + for (const childKey of childKeys) { + if (spec.elements[childKey]) { + walk(childKey); + } + } + } + } }; if (spec.elements[spec.root]) { walk(spec.root); @@ -454,6 +487,32 @@ export function autoFixSpec( fixedElements[key] = { ...element, children: present }; } + if (applyLossy) + for (const [key, element] of Object.entries(fixedElements)) { + if (!element.slots) continue; + let changed = false; + const slots = Object.fromEntries( + Object.entries(element.slots).map(([slotName, childKeys]) => { + const present = childKeys.filter((child) => child in fixedElements); + if (present.length !== childKeys.length) { + changed = true; + for (const child of childKeys) { + if (!(child in fixedElements)) { + fixes.push( + `Removed reference to undefined element "${child}" from slot "${slotName}" of "${key}".`, + true, + ); + } + } + } + return [slotName, present]; + }), + ); + if (changed) { + fixedElements[key] = { ...element, slots }; + } + } + return { spec: { root: spec.root, elements: fixedElements, state: spec.state }, fixes: fixDetails.map((fix) => fix.message), diff --git a/packages/core/src/types.test.ts b/packages/core/src/types.test.ts index 97f0458..31701a9 100644 --- a/packages/core/src/types.test.ts +++ b/packages/core/src/types.test.ts @@ -826,6 +826,27 @@ describe("nestedToFlat", () => { expect(spec.elements["el-2"]!.children).toEqual([]); }); + it("converts nested named slots to flat element references", () => { + const spec = nestedToFlat({ + type: "Layout", + props: {}, + children: [{ type: "Text", props: { content: "Main" } }], + slots: { + header: [{ type: "Heading", props: { text: "Header" } }], + footer: [{ type: "Button", props: { label: "Continue" } }], + }, + }); + + expect(Object.keys(spec.elements)).toHaveLength(4); + expect(spec.elements["el-0"]!.children).toEqual(["el-1"]); + expect(spec.elements["el-0"]!.slots).toEqual({ + header: ["el-2"], + footer: ["el-3"], + }); + expect(spec.elements["el-2"]!.type).toBe("Heading"); + expect(spec.elements["el-3"]!.type).toBe("Button"); + }); + it("hoists state from root node", () => { const spec = nestedToFlat({ type: "Card", diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 1a9ecee..c051449 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -65,6 +65,7 @@ export interface UIElement< props: P; /** Child element keys (flat structure) */ children?: string[]; + slots?: Record; /** Visibility condition */ visible?: VisibilityCondition; /** Event bindings — maps event names to action bindings */ @@ -685,6 +686,7 @@ interface NestedNode { type: string; props: Record; children?: NestedNode[]; + slots?: Record; /** Any other top-level fields (visible, on, repeat, etc.) */ [key: string]: unknown; } @@ -727,7 +729,13 @@ export function nestedToFlat(nested: Record): Spec { function walk(node: Record): string { const key = `el-${counter++}`; - const { type, props, children: rawChildren, ...rest } = node as NestedNode; + const { + type, + props, + children: rawChildren, + slots: rawSlots, + ...rest + } = node as NestedNode; // Recursively flatten children const childKeys: string[] = []; @@ -739,12 +747,25 @@ export function nestedToFlat(nested: Record): Spec { } } + const slots: Record = {}; + if (rawSlots && typeof rawSlots === "object") { + for (const [slotName, slotChildren] of Object.entries(rawSlots)) { + if (!Array.isArray(slotChildren)) continue; + slots[slotName] = slotChildren.flatMap((child) => + child && typeof child === "object" && "type" in child + ? [walk(child as Record)] + : [], + ); + } + } + // Build the flat element, preserving extra fields (visible, on, repeat, etc.) // but excluding `state` which is hoisted to spec-level. const element: UIElement = { type: type ?? "unknown", props: (props as Record) ?? {}, children: childKeys, + ...(Object.keys(slots).length > 0 ? { slots } : {}), }; // Copy extra fields (visible, on, repeat) but not state diff --git a/packages/devtools/src/panel/tabs/spec.ts b/packages/devtools/src/panel/tabs/spec.ts index 0508b90..90c4fa3 100644 --- a/packages/devtools/src/panel/tabs/spec.ts +++ b/packages/devtools/src/panel/tabs/spec.ts @@ -223,7 +223,8 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance { return; } const el = spec.elements[current]; - const hasChildren = !!el?.children?.length; + const childKeys = getChildKeys(el); + const hasChildren = childKeys.length > 0; if (!hasChildren) return; if (!expanded.has(current)) { expanded.add(current); @@ -232,7 +233,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance { scrollSelectedIntoView(); } else { // Already expanded → step into the first child. - moveSelection(el.children![0]); + moveSelection(childKeys[0]); } return; } @@ -240,7 +241,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance { if (key === "ArrowLeft") { if (!current) return; const el = spec.elements[current]; - const hasChildren = !!el?.children?.length; + const hasChildren = getChildKeys(el).length > 0; if (hasChildren && expanded.has(current)) { expanded.delete(current); render(); @@ -258,7 +259,7 @@ function mountSpecTab(root: HTMLElement, ctx: PanelContext): TabInstance { return; } const el = spec.elements[current]; - if (el?.children?.length) { + if (getChildKeys(el).length > 0) { toggleExpanded(current); scrollSelectedIntoView(); } @@ -510,9 +511,10 @@ function collectVisibleKeys(spec: Spec, expanded: Set): string[] { function walk(key: string) { list.push(key); const el = spec.elements[key]; - if (!el?.children || el.children.length === 0) return; + const childKeys = getChildKeys(el); + if (childKeys.length === 0) return; if (!expanded.has(key)) return; - for (const child of el.children) walk(child); + for (const child of childKeys) walk(child); } walk(spec.root); return list; @@ -520,11 +522,19 @@ function collectVisibleKeys(spec: Spec, expanded: Set): string[] { function findParent(spec: Spec, key: string): string | null { for (const [parentKey, el] of Object.entries(spec.elements)) { - if (el.children?.includes(key)) return parentKey; + if (getChildKeys(el).includes(key)) return parentKey; } return null; } +function getChildKeys(element: UIElement | undefined): string[] { + if (!element) return []; + return [ + ...(element.children ?? []), + ...Object.values(element.slots ?? {}).flat(), + ]; +} + interface IssueIndex { all: SpecIssue[]; byKey: Map; @@ -554,8 +564,7 @@ function findPath(spec: Spec, key: string): string[] { return true; } const el = spec.elements[current]; - if (!el?.children) return false; - for (const child of el.children) { + for (const child of getChildKeys(el)) { if (walk(child)) { path.push(current); return true; @@ -592,7 +601,8 @@ function renderNode( ); } - const hasChildren = Array.isArray(el.children) && el.children.length > 0; + const childKeys = getChildKeys(el); + const hasChildren = childKeys.length > 0; const isExpanded = hasChildren && expanded.has(key); const isSelected = selected === key; const elementIssues = issues.byKey.get(key) ?? []; @@ -657,7 +667,7 @@ function renderNode( const container = h("div", null, row); if (isExpanded && hasChildren) { - for (const childKey of el.children!) { + for (const childKey of childKeys) { const childNode = renderNode( spec, childKey, @@ -718,7 +728,7 @@ function renderDetail( } const elIssues = issues.byKey.get(key) ?? []; - const children = el.children?.length ?? 0; + const children = getChildKeys(el).length; replaceChildren( container, diff --git a/packages/react/README.md b/packages/react/README.md index 2ebf3db..6f41eae 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -67,9 +67,7 @@ export const { registry } = defineRegistry(catalog, { ), Button: ({ props, emit }) => ( - + ), Input: ({ props, bindings }) => { const [value, setValue] = useBoundProp(props.value, bindings?.value); @@ -97,9 +95,11 @@ import { registry } from "./registry"; function App({ spec }) { return ( - console.log("Submit"), - }}> + console.log("Submit"), + }} + > @@ -113,19 +113,22 @@ The React renderer uses a flat element map format: ```typescript interface Spec { - root: string; // Key of the root element - elements: Record; // Flat map of elements by key - state?: Record; // Optional initial state + root: string; // Key of the root element + elements: Record; // Flat map of elements by key + state?: Record; // Optional initial state } interface UIElement { - type: string; // Component name from catalog - props: Record; // Component props - children?: string[]; // Keys of child elements - visible?: VisibilityCondition; // Visibility condition + type: string; // Component name from catalog + props: Record; // Component props + children?: string[]; // Keys of child elements + slots?: Record; // Named slots mapped to child keys + visible?: VisibilityCondition; // Visibility condition } ``` +The `slots` element field is a React renderer feature. Other renderer packages may only use catalog slot declarations for default children. + Example spec: ```json @@ -163,11 +166,11 @@ Share data across components with JSON Pointer paths: ```tsx {children} - +; // In components: const { state, get, set } = useStateStore(); -const name = get("/user/name"); // "John" +const name = get("/user/name"); // "John" set("/user/age", 25); ``` @@ -181,9 +184,7 @@ import { createStateStore, type StateStore } from "@json-render/react"; // Option 1: Use the built-in store outside of React const store = createStateStore({ count: 0 }); - - {children} - +{children}; // Mutate from anywhere — React will re-render automatically: store.set("/count", 1); @@ -191,8 +192,14 @@ store.set("/count", 1); // Option 2: Implement the StateStore interface with your own backend const zustandStore: StateStore = { get: (path) => getByPath(useStore.getState(), path), - set: (path, value) => useStore.setState(prev => { /* ... */ }), - update: (updates) => useStore.setState(prev => { /* ... */ }), + set: (path, value) => + useStore.setState((prev) => { + /* ... */ + }), + update: (updates) => + useStore.setState((prev) => { + /* ... */ + }), getSnapshot: () => useStore.getState(), subscribe: (listener) => useStore.subscribe(listener), }; @@ -237,9 +244,7 @@ Control element visibility based on data: Add field validation: ```tsx - - {children} - +{children}; // Use validation hooks: const { errors, validate } = useFieldValidation("/form/email", { @@ -252,17 +257,17 @@ const { errors, validate } = useFieldValidation("/form/email", { ## Hooks -| Hook | Purpose | -|------|---------| -| `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) | -| `useStateValue(path)` | Get single value from state | -| `useStateBinding(path)` | Two-way data binding (returns `[value, setValue]`) | -| `useIsVisible(condition)` | Check if a visibility condition is met | -| `useActions()` | Access action context | -| `useAction(name)` | Get a single action dispatch function | -| `useFieldValidation(path, config)` | Field validation state | -| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) | -| `useUIStream(options)` | Stream specs from an API endpoint | +| Hook | Purpose | +| ---------------------------------- | --------------------------------------------------------------- | +| `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) | +| `useStateValue(path)` | Get single value from state | +| `useStateBinding(path)` | Two-way data binding (returns `[value, setValue]`) | +| `useIsVisible(condition)` | Check if a visibility condition is met | +| `useActions()` | Access action context | +| `useAction(name)` | Get a single action dispatch function | +| `useFieldValidation(path, config)` | Field validation state | +| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) | +| `useUIStream(options)` | Stream specs from an API endpoint | ## Visibility Conditions @@ -296,13 +301,13 @@ TypeScript helpers from `@json-render/core`: ```typescript import { visibility } from "@json-render/core"; -visibility.when("/path") // { $state: "/path" } -visibility.unless("/path") // { $state: "/path", not: true } -visibility.eq("/path", val) // { $state: "/path", eq: val } -visibility.neq("/path", val) // { $state: "/path", neq: val } -visibility.and(cond1, cond2) // { $and: [cond1, cond2] } -visibility.always // true -visibility.never // false +visibility.when("/path"); // { $state: "/path" } +visibility.unless("/path"); // { $state: "/path", not: true } +visibility.eq("/path", val); // { $state: "/path", eq: val } +visibility.neq("/path", val); // { $state: "/path", neq: val } +visibility.and(cond1, cond2); // { $and: [cond1, cond2] } +visibility.always; // true +visibility.never; // false ``` ## Dynamic Prop Expressions @@ -422,21 +427,34 @@ When using `defineRegistry`, components receive these props: ```typescript interface ComponentContext

{ - props: P; // Typed props from the catalog (expressions resolved) - children?: React.ReactNode; // Rendered children - emit: (event: string) => void; // Emit a named event (always defined) + props: P; // Typed props from the catalog (expressions resolved) + children?: React.ReactNode; // Rendered children + slots?: Record; // Rendered named slots + emit: (event: string) => void; // Emit a named event (always defined) on: (event: string) => EventHandle; // Get event handle with metadata - loading?: boolean; // Whether the parent is loading - bindings?: Record; // State paths for $bindState/$bindItem expressions (e.g. bindings.value) + loading?: boolean; // Whether the parent is loading + bindings?: Record; // State paths for $bindState/$bindItem expressions (e.g. bindings.value) } interface EventHandle { - emit: () => void; // Fire the event + emit: () => void; // Fire the event shouldPreventDefault: boolean; // Whether any binding requested preventDefault - bound: boolean; // Whether any handler is bound + bound: boolean; // Whether any handler is bound } ``` +Use `children` for the catalog's `"default"` slot. Components with additional slots receive them by name: + +```tsx +Layout: ({ children, slots }) => ( +

+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+), +``` + Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`: ```tsx @@ -521,27 +539,27 @@ function App() { Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item. -| Export | Purpose | -|--------|---------| -| `defineRegistry` | Create a type-safe component registry from a catalog | -| `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) | -| `useStateStore` | Access state context | -| `useStateValue` | Get single value from state | -| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | -| `useActions` | Access actions context | -| `useAction` | Get a single action dispatch function | -| `useUIStream` | Stream specs from an API endpoint | -| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | +| Export | Purpose | +| ------------------ | ------------------------------------------------------------------------------------------------------- | +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `Renderer` | Render a spec using a registry | +| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) | +| `useStateStore` | Access state context | +| `useStateValue` | Get single value from state | +| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | +| `useActions` | Access actions context | +| `useAction` | Get a single action dispatch function | +| `useUIStream` | Stream specs from an API endpoint | +| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | ### Types -| Export | Purpose | -|--------|---------| -| `ComponentContext` | Typed component render function context (catalog-aware) | +| Export | Purpose | +| -------------------- | ----------------------------------------------------------- | +| `ComponentContext` | Typed component render function context (catalog-aware) | | `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | -| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` | -| `ComponentFn` | Component render function type | -| `SetState` | State setter type | -| `StateModel` | State model type | -| `StateStore` | Interface for plugging in external state management | +| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` | +| `ComponentFn` | Component render function type | +| `SetState` | State setter type | +| `StateModel` | State model type | +| `StateStore` | Interface for plugging in external state management | diff --git a/packages/react/src/catalog-types.ts b/packages/react/src/catalog-types.ts index d7187ef..f8e3868 100644 --- a/packages/react/src/catalog-types.ts +++ b/packages/react/src/catalog-types.ts @@ -60,6 +60,7 @@ export interface EventHandle { export interface BaseComponentProps

> { props: P; children?: ReactNode; + slots?: Record; /** Simple event emitter (shorthand). Fires the event and returns void. */ emit: (event: string) => void; /** Get an event handle with metadata. Use when you need shouldPreventDefault or bound checks. */ diff --git a/packages/react/src/hooks.test.ts b/packages/react/src/hooks.test.ts index fad455c..aff3fc2 100644 --- a/packages/react/src/hooks.test.ts +++ b/packages/react/src/hooks.test.ts @@ -311,6 +311,31 @@ describe("buildSpecFromParts", () => { expect(childEl!.props.content).toBe("Child"); }); + it("preserves named slots in nested spec parts", () => { + const spec = buildSpecFromParts([ + { + type: "data-spec", + data: { + type: "nested", + spec: { + type: "Layout", + props: {}, + slots: { + header: [ + { type: "Heading", props: { text: "Header" }, children: [] }, + ], + }, + }, + }, + }, + ]); + + expect(spec).not.toBeNull(); + const root = spec!.elements[spec!.root]!; + expect(root.slots?.header).toHaveLength(1); + expect(spec!.elements[root.slots!.header![0]!]!.type).toBe("Heading"); + }); + it("handles mixed patch + flat + nested parts in sequence", () => { const parts = [ // Start with a patch diff --git a/packages/react/src/renderer.test.tsx b/packages/react/src/renderer.test.tsx index 1b904d7..fe1a3af 100644 --- a/packages/react/src/renderer.test.tsx +++ b/packages/react/src/renderer.test.tsx @@ -1,12 +1,15 @@ import { describe, it, expect, vi } from "vitest"; import React from "react"; import { render, screen } from "@testing-library/react"; -import type { Spec } from "@json-render/core"; +import { defineCatalog, type Spec } from "@json-render/core"; +import { z } from "zod"; import { + defineRegistry, JSONUIProvider, Renderer, type ComponentRenderProps, } from "./renderer"; +import { schema } from "./schema"; describe("Renderer", () => { it("renders null for null spec", () => { @@ -47,6 +50,61 @@ describe("Renderer", () => { expect(element.props.fallback).toBe(Fallback); }); + it("renders named slots through defineRegistry", () => { + const catalog = defineCatalog(schema, { + components: { + Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + }, + Text: { + props: z.object({ text: z.string() }), + slots: [], + }, + }, + actions: {}, + }); + const { registry } = defineRegistry(catalog, { + components: { + Layout: ({ children, slots }) => ( +

+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), + Text: ({ props }) => {props.text}, + }, + }); + const spec: Spec = { + root: "layout", + elements: { + layout: { + type: "Layout", + props: {}, + children: ["main"], + slots: { + header: ["header"], + footer: ["footer"], + }, + }, + header: { type: "Text", props: { text: "Header" } }, + main: { type: "Text", props: { text: "Main" } }, + footer: { type: "Text", props: { text: "Footer" } }, + }, + }; + + render( + + + , + ); + + expect(screen.getByTestId("header-slot").textContent).toBe("Header"); + expect(screen.getByTestId("default-slot").textContent).toBe("Main"); + expect(screen.getByTestId("footer-slot").textContent).toBe("Footer"); + }); + it.each(["subitems", "/subitems"])( "resolves nested repeat statePath %s from parent $item scope", (itemPath) => { diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index b4dd13a..9b353e1 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -62,6 +62,7 @@ export interface ComponentRenderProps

> { element: UIElement; /** Rendered children */ children?: ReactNode; + slots?: Record; /** Emit a named event. The renderer resolves the event to action binding(s) from the element's `on` field. Always provided by the renderer. */ emit: (event: string) => void; /** Get an event handle with metadata (shouldPreventDefault, bound). Use when you need to inspect event bindings. */ @@ -88,6 +89,11 @@ export type ComponentRenderer

> = ComponentType< */ export type ComponentRegistry = Record>; +const registryMetadata = new WeakMap< + ComponentRegistry, + Record +>(); + /** * Props for the Renderer component */ @@ -397,23 +403,32 @@ const ElementRenderer = React.memo(function ElementRenderer({ return null; } - // ---- Render children (with repeat support) ---- - const children = resolvedElement.repeat ? ( - - ) : ( - resolvedElement.children?.map((childKey) => { + const metadata = registryMetadata.get(registry)?.[resolvedElement.type]; + if (resolvedElement.slots && metadata?.slots) { + const availableSlots = new Set(metadata.slots); + for (const slotName of Object.keys(resolvedElement.slots)) { + if (slotName === "default") { + console.warn( + `[json-render] Component "${resolvedElement.type}" uses slots.default. Use "children" for default slot content.`, + ); + } else if (!availableSlots.has(slotName)) { + console.warn( + `[json-render] Unknown slot "${slotName}" on component "${resolvedElement.type}". Available slots: ${metadata.slots.join(", ")}`, + ); + } + } + } + + const renderChildKeys = (childKeys: string[], slotName?: string) => + childKeys.map((childKey) => { const childElement = spec.elements[childKey]; if (!childElement) { if (!loading) { + const location = slotName + ? `in slot "${slotName}" of "${resolvedElement.type}"` + : `as child of "${resolvedElement.type}"`; console.warn( - `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, + `[json-render] Missing element "${childKey}" referenced ${location}. This element will not render.`, ); } return null; @@ -429,12 +444,34 @@ const ElementRenderer = React.memo(function ElementRenderer({ fallback={fallback} /> ); - }) - ); + }); + + const children = resolvedElement.repeat ? ( + + ) : resolvedElement.children ? ( + renderChildKeys(resolvedElement.children) + ) : undefined; + + const slots = resolvedElement.slots + ? Object.fromEntries( + Object.entries(resolvedElement.slots).map(([slotName, childKeys]) => [ + slotName, + renderChildKeys(childKeys, slotName), + ]), + ) + : undefined; const rendered = ( = { * ``` */ export function defineRegistry( - _catalog: C, + catalog: C, options: DefineRegistryOptions, ): DefineRegistryResult { // Build component registry @@ -758,6 +795,7 @@ export function defineRegistry( registry[name] = ({ element, children, + slots, emit, on, bindings, @@ -766,6 +804,7 @@ export function defineRegistry( return (componentFn as DefineRegistryComponentFn)({ props: element.props, children, + slots, emit, on, bindings, @@ -774,6 +813,12 @@ export function defineRegistry( }; } } + const catalogComponents = ( + catalog.data as { components?: Record } + ).components; + if (catalogComponents) { + registryMetadata.set(registry, catalogComponents); + } // Build action helpers const actionMap = options.actions @@ -823,6 +868,7 @@ export function defineRegistry( type DefineRegistryComponentFn = (ctx: { props: unknown; children?: React.ReactNode; + slots?: Record; emit: (event: string) => void; on: (event: string) => EventHandle; bindings?: Record; @@ -906,6 +952,12 @@ export function createRenderer< // Convert component map to registry const registry: ComponentRegistry = components as unknown as ComponentRegistry; + const catalogComponents = ( + catalog.data as { components?: Record } + ).components; + if (catalogComponents) { + registryMetadata.set(registry, catalogComponents); + } // Return the renderer component return function CatalogRenderer({ diff --git a/packages/react/src/schema.ts b/packages/react/src/schema.ts index a821913..ce1f785 100644 --- a/packages/react/src/schema.ts +++ b/packages/react/src/schema.ts @@ -22,6 +22,7 @@ export const schema = defineSchema( props: s.propsOf("catalog.components"), /** Child element keys (flat reference) */ children: s.array(s.string()), + slots: { ...s.record(s.array(s.string())), ...s.optional() }, /** Visibility condition */ visible: { ...s.any(), ...s.optional() }, /** Repeat children from a state array */ @@ -80,6 +81,7 @@ export const schema = defineSchema( "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.', + 'NAMED SLOTS: Use "children" for the default slot. For other slots declared by the component, use a top-level "slots" object that maps each slot name to child element keys, for example {"slots":{"header":["heading"],"footer":["actions"]}}. Never use "slots.default". Every referenced key must exist.', 'FILTERED LISTS: To render only the items matching a field value (kanban columns, tabbed lists, status sections), put "repeat" and a "visible" condition with $item on the same container element: {"repeat": {"statePath": "/tasks", "key": "id"}, "visible": {"$item": "status", "eq": "todo"}} renders one child per matching item. A visible condition object must use exactly one of $state, $item, or $index — never combine them in one object.', // Field placement diff --git a/skills/react/SKILL.md b/skills/react/SKILL.md index 3f71719..d18f5f4 100644 --- a/skills/react/SKILL.md +++ b/skills/react/SKILL.md @@ -44,8 +44,14 @@ export const catalog = defineCatalog(schema, { }, Card: { props: z.object({ title: z.string() }), + slots: ["default"], description: "Card container with title", }, + Layout: { + props: z.object({}), + slots: ["default", "header", "footer"], + description: "Layout with named content regions", + }, }, }); @@ -61,6 +67,13 @@ const { registry } = defineRegistry(catalog, { {children} ), + Layout: ({ children, slots }) => ( +

+
{slots?.header}
+
{children}
+
{slots?.footer}
+
+ ), }, }); ``` @@ -74,25 +87,41 @@ The React schema uses an element tree format: "root": { "type": "Card", "props": { "title": "Hello" }, - "children": [ - { "type": "Button", "props": { "label": "Click me" } } - ] + "children": [{ "type": "Button", "props": { "label": "Click me" } }] } } ``` +## Named Slots + +Use `children` for the `"default"` slot. Use the element's top-level `slots` object for other slot names declared by the catalog: + +```json +{ + "type": "Layout", + "props": {}, + "children": ["main"], + "slots": { + "header": ["heading"], + "footer": ["actions"] + } +} +``` + +Registry components receive named content as `slots?.header`, `slots?.footer`, and so on. Do not use `slots.default`. + ## Visibility Conditions Use `visible` on elements to show/hide based on state. New syntax: `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`, `{ "$state": "/path", "not": true }`, `{ "$and": [cond1, cond2] }` for AND, `{ "$or": [cond1, cond2] }` for OR. Helpers: `visibility.when("/path")`, `visibility.unless("/path")`, `visibility.eq("/path", val)`, `visibility.and(cond1, cond2)`, `visibility.or(cond1, cond2)`. ## Providers -| Provider | Purpose | -|----------|---------| -| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | -| `ActionProvider` | Handle actions dispatched via the event system | -| `VisibilityProvider` | Enable conditional rendering based on state | -| `ValidationProvider` | Form field validation | +| Provider | Purpose | +| -------------------- | ------------------------------------------------------------------------------------------------------ | +| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. | +| `ActionProvider` | Handle actions dispatched via the event system | +| `VisibilityProvider` | Enable conditional rendering based on state | +| `ValidationProvider` | Form field validation | ### External Store (Controlled Mode) @@ -103,7 +132,7 @@ import { createStateStore, type StateStore } from "@json-render/react"; const store = createStateStore({ count: 0 }); -{children} +{children}; // Mutate from anywhere — React re-renders automatically: store.set("/count", 1); @@ -185,7 +214,10 @@ Elements can declare a `watch` field (top-level, sibling of type/props/children) ```json { "type": "Select", - "props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] }, + "props": { + "value": { "$bindState": "/form/country" }, + "options": ["US", "Canada"] + }, "watch": { "/form/country": { "action": "loadCities" } }, "children": [] } @@ -247,20 +279,20 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => ( ## Key Exports -| Export | Purpose | -|--------|---------| -| `defineRegistry` | Create a type-safe component registry from a catalog | -| `Renderer` | Render a spec using a registry | -| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | -| `useStateStore` | Access state context | -| `useStateValue` | Get single value from state | -| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | -| `useActions` | Access actions context | -| `useAction` | Get a single action dispatch function | -| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | -| `useUIStream` | Stream specs from an API endpoint | -| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | -| `StateStore` | Interface for plugging in external state management | -| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | -| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | -| `ComponentContext` | Typed component context (catalog-aware) | +| Export | Purpose | +| ----------------------- | ----------------------------------------------------------------------------------------------------- | +| `defineRegistry` | Create a type-safe component registry from a catalog | +| `Renderer` | Render a spec using a registry | +| `schema` | Element tree schema (includes built-in state actions: setState, pushState, removeState, validateForm) | +| `useStateStore` | Access state context | +| `useStateValue` | Get single value from state | +| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | +| `useActions` | Access actions context | +| `useAction` | Get a single action dispatch function | +| `useOptionalValidation` | Non-throwing variant of useValidation (returns null if no provider) | +| `useUIStream` | Stream specs from an API endpoint | +| `createStateStore` | Create a framework-agnostic in-memory `StateStore` | +| `StateStore` | Interface for plugging in external state management | +| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries | +| `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) | +| `ComponentContext` | Typed component context (catalog-aware) |