feat(react): support named slots (#320)

Co-authored-by: wotnak <wotnak@pm.me>
This commit is contained in:
Railly Hugo
2026-08-14 22:22:49 -03:00
committed by GitHub
co-authored by wotnak
parent 9f58a3cade
commit 0f6798b193
24 changed files with 807 additions and 229 deletions
+9 -7
View File
@@ -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<string, unknown>;
children?: string[]; // Keys of child elements
slots?: Record<string, string[]>; // Named slots mapped to child keys
visible?: VisibilityCondition;
on?: Record<string, ActionBinding | ActionBinding[]>; // 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
+25 -15
View File
@@ -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
+56 -39
View File
@@ -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 }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
<button onClick={() => emit("press")}>{props.label}</button>
),
},
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<string, string>; // State paths from $bindState/$bindItem expressions
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
slots?: Record<string, React.ReactNode>; // 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<string, string>; // 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 }) => (
<div>
<header>{slots?.header}</header>
<main>{children}</main>
<footer>{slots?.footer}</footer>
</div>
),
```
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 `<Renderer />` 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 }) => (
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
<Container style={{ padding: 16, backgroundColor: "#fff" }}>
<Heading>{props.title}</Heading>
{children}
</Container>
@@ -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
<Renderer spec={timelineSpec} components={standardComponents} />
<Renderer spec={timelineSpec} components={standardComponents} />;
// Or extend with your own
const components = {
+46 -22
View File
@@ -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 (
<Renderer
spec={spec}
registry={registry}
loading={isStreaming}
/>
);
return <Renderer spec={spec} registry={registry} loading={isStreaming} />;
}
```
+40 -7
View File
@@ -195,6 +195,15 @@ function specToNested(spec: Spec): Record<string, unknown> {
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));
}
+40 -7
View File
@@ -152,6 +152,15 @@ function specToNested(spec: Spec): Record<string, unknown> {
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));
}
+4
View File
@@ -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];
+27
View File
@@ -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", () => {
+8
View File
@@ -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);
+2 -2
View File
@@ -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;
+4 -1
View File
@@ -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<string>");
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", () => {
+32 -3
View File
@@ -660,6 +660,21 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
const allComponents = (catalog.data as Record<string, unknown>).components as
| Record<string, CatalogComponentDef>
| undefined;
const specDefinition = catalog.schema.definition.spec;
const specShape =
specDefinition.kind === "object"
? (specDefinition.inner as Record<string, SchemaType>)
: undefined;
const elementsDefinition = specShape?.elements;
const elementDefinition =
elementsDefinition?.kind === "record"
? (elementsDefinition.inner as SchemaType)
: undefined;
const elementShape =
elementDefinition?.kind === "object"
? (elementDefinition.inner as Record<string, SchemaType>)
: 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("");
}
+89
View File
@@ -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",
+59
View File
@@ -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),
+21
View File
@@ -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",
+22 -1
View File
@@ -65,6 +65,7 @@ export interface UIElement<
props: P;
/** Child element keys (flat structure) */
children?: string[];
slots?: Record<string, string[]>;
/** Visibility condition */
visible?: VisibilityCondition;
/** Event bindings — maps event names to action bindings */
@@ -685,6 +686,7 @@ interface NestedNode {
type: string;
props: Record<string, unknown>;
children?: NestedNode[];
slots?: Record<string, NestedNode[]>;
/** Any other top-level fields (visible, on, repeat, etc.) */
[key: string]: unknown;
}
@@ -727,7 +729,13 @@ export function nestedToFlat(nested: Record<string, unknown>): Spec {
function walk(node: Record<string, unknown>): 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<string, unknown>): Spec {
}
}
const slots: Record<string, string[]> = {};
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<string, unknown>)]
: [],
);
}
}
// 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<string, unknown>) ?? {},
children: childKeys,
...(Object.keys(slots).length > 0 ? { slots } : {}),
};
// Copy extra fields (visible, on, repeat) but not state
+22 -12
View File
@@ -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>): 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>): 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<string, SpecIssue[]>;
@@ -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,
+86 -68
View File
@@ -67,9 +67,7 @@ export const { registry } = defineRegistry(catalog, {
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>
{props.label}
</button>
<button onClick={() => emit("press")}>{props.label}</button>
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp(props.value, bindings?.value);
@@ -97,9 +95,11 @@ import { registry } from "./registry";
function App({ spec }) {
return (
<StateProvider initialState={{ form: { name: "" } }}>
<ActionProvider handlers={{
submit: () => console.log("Submit"),
}}>
<ActionProvider
handlers={{
submit: () => console.log("Submit"),
}}
>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</StateProvider>
@@ -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<string, UIElement>; // Flat map of elements by key
state?: Record<string, unknown>; // Optional initial state
root: string; // Key of the root element
elements: Record<string, UIElement>; // Flat map of elements by key
state?: Record<string, unknown>; // Optional initial state
}
interface UIElement {
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
visible?: VisibilityCondition; // Visibility condition
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
slots?: Record<string, string[]>; // 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
<StateProvider initialState={{ user: { name: "John" } }}>
{children}
</StateProvider>
</StateProvider>;
// 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 });
<StateProvider store={store}>
{children}
</StateProvider>
<StateProvider store={store}>{children}</StateProvider>;
// 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
<ValidationProvider>
{children}
</ValidationProvider>
<ValidationProvider>{children}</ValidationProvider>;
// 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<P> {
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<string, React.ReactNode>; // 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<string, string>; // State paths for $bindState/$bindItem expressions (e.g. bindings.value)
loading?: boolean; // Whether the parent is loading
bindings?: Record<string, string>; // 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 }) => (
<div>
<header>{slots?.header}</header>
<main>{children}</main>
<footer>{slots?.footer}</footer>
</div>
),
```
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 |
+1
View File
@@ -60,6 +60,7 @@ export interface EventHandle {
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: ReactNode;
slots?: Record<string, ReactNode>;
/** 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. */
+25
View File
@@ -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
+59 -1
View File
@@ -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 }) => (
<section>
<header data-testid="header-slot">{slots?.header}</header>
<main data-testid="default-slot">{children}</main>
<footer data-testid="footer-slot">{slots?.footer}</footer>
</section>
),
Text: ({ props }) => <span>{props.text}</span>,
},
});
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(
<JSONUIProvider registry={registry}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
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) => {
+68 -16
View File
@@ -62,6 +62,7 @@ export interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
/** Rendered children */
children?: ReactNode;
slots?: Record<string, ReactNode>;
/** 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<P = Record<string, unknown>> = ComponentType<
*/
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
const registryMetadata = new WeakMap<
ComponentRegistry,
Record<string, { slots?: string[] }>
>();
/**
* 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 ? (
<RepeatChildren
element={resolvedElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
itemFilter={repeatItemFilter}
/>
) : (
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 ? (
<RepeatChildren
element={resolvedElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
itemFilter={repeatItemFilter}
/>
) : 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 = (
<Component
element={resolvedElement}
slots={slots}
emit={emit}
on={on}
bindings={elementBindings}
@@ -748,7 +785,7 @@ type DefineRegistryOptions<C extends Catalog> = {
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
catalog: C,
options: DefineRegistryOptions<C>,
): DefineRegistryResult {
// Build component registry
@@ -758,6 +795,7 @@ export function defineRegistry<C extends Catalog>(
registry[name] = ({
element,
children,
slots,
emit,
on,
bindings,
@@ -766,6 +804,7 @@ export function defineRegistry<C extends Catalog>(
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
slots,
emit,
on,
bindings,
@@ -774,6 +813,12 @@ export function defineRegistry<C extends Catalog>(
};
}
}
const catalogComponents = (
catalog.data as { components?: Record<string, { slots?: string[] }> }
).components;
if (catalogComponents) {
registryMetadata.set(registry, catalogComponents);
}
// Build action helpers
const actionMap = options.actions
@@ -823,6 +868,7 @@ export function defineRegistry<C extends Catalog>(
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
slots?: Record<string, React.ReactNode>;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
@@ -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<string, { slots?: string[] }> }
).components;
if (catalogComponents) {
registryMetadata.set(registry, catalogComponents);
}
// Return the renderer component
return function CatalogRenderer({
+2
View File
@@ -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
+60 -28
View File
@@ -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}
</div>
),
Layout: ({ children, slots }) => (
<div>
<header>{slots?.header}</header>
<main>{children}</main>
<footer>{slots?.footer}</footer>
</div>
),
},
});
```
@@ -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 });
<StateProvider store={store}>{children}</StateProvider>
<StateProvider store={store}>{children}</StateProvider>;
// 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) |