mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 03:54:41 +08:00
feat(react): support named slots (#320)
Co-authored-by: wotnak <wotnak@pm.me>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("");
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user