mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea4b361b9f | ||
|
|
0f6798b193 | ||
|
|
9f58a3cade | ||
|
|
9d3dfc8917 |
+32
-2
@@ -1,8 +1,39 @@
|
||||
# Changelog
|
||||
|
||||
## 0.20.0
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
### New Features
|
||||
|
||||
- **Named slots for React:** Components can declare named slots such as `header` and `footer`, while `children` remains the default slot. Slots are preserved through validation, streaming, nested conversion, code export, playground views, and Devtools navigation (#320). Built from the original contribution by @wotnak in #105
|
||||
- **Nested repeats:** `repeat.statePath` now accepts item-relative paths such as `{ "$item": "employees" }`, enabling nested data rendering across React, React Native, React Email, React PDF, Image, Ink, Solid, Svelte, and Vue (#319). Built from the original contribution by @tmchow in #256
|
||||
- **Harness chat example:** Added a complete Next.js example using the AI SDK 7 harness adapter, agent delegation, sandbox transport, and json-render components (#302)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Chained action params:** Named `onSuccess` and `onError` actions now receive their configured `params` across core and all renderer bridges (#307)
|
||||
- **Consistent optional visibility:** Element `visible` fields now remain optional across Zod 4 versions, while prompts explicitly require `children` arrays for every element (#299)
|
||||
- **Spec validation and autofix:** Dangling child references are pruned, malformed visibility conditions are reported, and repeated items can be filtered safely (#300)
|
||||
|
||||
### Improvements
|
||||
|
||||
- **Release toolchain hardening:** The workspace now requires Node.js 24 and pnpm 11, enforces package engine checks, and applies a minimum package release age (#293)
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Custom renderer bridges that implement the core `executeAction` callback must now accept an `ActionBinding` instead of a bare action name. This exposes chained action params to custom integrations at compile time (#307)
|
||||
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
- @Railly
|
||||
- @tmchow
|
||||
- @wotnak
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.19.0
|
||||
|
||||
<!-- release:start -->
|
||||
### New Features
|
||||
|
||||
- **Custom directives API** — `@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally — nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution (#279)
|
||||
@@ -15,7 +46,6 @@
|
||||
### Contributors
|
||||
|
||||
- @ctate
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.18.0
|
||||
|
||||
|
||||
@@ -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,9 +649,10 @@ 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; key?: string }; // Repeat for arrays
|
||||
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
|
||||
|
||||
|
||||
@@ -5,6 +5,72 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.20.0
|
||||
|
||||
August 15, 2026
|
||||
|
||||
### New: Named Slots for React
|
||||
|
||||
React components can now declare named slots such as `header` and `footer`, while `children` remains the default slot. Named slots are preserved through validation, streaming, nested conversion, code export, playground views, and Devtools navigation.
|
||||
|
||||
This work builds on the original named slots contribution by @wotnak.
|
||||
|
||||
```tsx
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["default", "header", "footer"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Layout: ({ children, slots }) => (
|
||||
<section>
|
||||
<header>{slots?.header}</header>
|
||||
<main>{children}</main>
|
||||
<footer>{slots?.footer}</footer>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### New: Nested Repeats
|
||||
|
||||
`repeat.statePath` now accepts item-relative paths such as `{ "$item": "employees" }`. Nested data can be rendered across React, React Native, React Email, React PDF, Image, Ink, Solid, Svelte, and Vue.
|
||||
|
||||
This work builds on the original nested repeats contribution by @tmchow.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Table",
|
||||
"repeat": { "statePath": { "$item": "employees" }, "key": "id" },
|
||||
"children": ["employee-row"],
|
||||
"props": {}
|
||||
}
|
||||
```
|
||||
|
||||
### New: Harness Chat Example
|
||||
|
||||
Added a complete Next.js example using the AI SDK 7 harness adapter, agent delegation, sandbox transport, and json-render components.
|
||||
|
||||
### Fixed: Chained Action Params
|
||||
|
||||
Named `onSuccess` and `onError` actions now receive their configured `params` across core and all renderer bridges. Custom renderer bridges must now accept an `ActionBinding` in the core `executeAction` callback instead of a bare action name.
|
||||
|
||||
### Improved: Spec Validation and Compatibility
|
||||
|
||||
Element visibility remains optional across Zod 4 versions. Autofix now prunes dangling child references, validation reports malformed visibility conditions, and repeated items can be filtered safely.
|
||||
|
||||
### Improved: Release Toolchain
|
||||
|
||||
The workspace now requires Node.js 24 and pnpm 11, enforces package engine checks, and applies a minimum package release age.
|
||||
|
||||
---
|
||||
|
||||
## v0.19.0
|
||||
|
||||
May 6, 2026
|
||||
|
||||
@@ -126,7 +126,7 @@ The `repeat` field on an element renders its children once per item in a state a
|
||||
}
|
||||
```
|
||||
|
||||
- `repeat.statePath` — JSON Pointer to the state array
|
||||
- `repeat.statePath`: root JSON Pointer to the state array, or `{ "$item": "field" }` for an array on the enclosing repeat item
|
||||
- `repeat.key` — field name on each item to use as a stable key for rendering
|
||||
|
||||
Inside `todo-item`, `{ "$item": "title" }` reads the `title` field from whichever array item is currently being rendered. `{ "$index": true }` would return `0` for the first item, `1` for the second, and so on.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -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`), 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;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
|
||||
@@ -4,8 +4,28 @@ import {
|
||||
executeAction,
|
||||
interpolateString,
|
||||
actionBinding,
|
||||
ActionOnSuccessSchema,
|
||||
ActionOnErrorSchema,
|
||||
} from "./actions";
|
||||
|
||||
describe("onSuccess/onError schemas", () => {
|
||||
it("keeps params on the onSuccess action form", () => {
|
||||
const parsed = ActionOnSuccessSchema.parse({
|
||||
action: "toast",
|
||||
params: { message: "Saved" },
|
||||
});
|
||||
expect(parsed).toEqual({ action: "toast", params: { message: "Saved" } });
|
||||
});
|
||||
|
||||
it("keeps params on the onError action form", () => {
|
||||
const parsed = ActionOnErrorSchema.parse({
|
||||
action: "toast",
|
||||
params: { message: "Failed" },
|
||||
});
|
||||
expect(parsed).toEqual({ action: "toast", params: { message: "Failed" } });
|
||||
});
|
||||
});
|
||||
|
||||
describe("interpolateString", () => {
|
||||
it("interpolates ${path} expressions", () => {
|
||||
const data = { user: { name: "Alice" }, count: 5 };
|
||||
@@ -161,7 +181,27 @@ describe("executeAction", () => {
|
||||
executeAction: executeActionFn,
|
||||
});
|
||||
|
||||
expect(executeActionFn).toHaveBeenCalledWith("followUp");
|
||||
expect(executeActionFn).toHaveBeenCalledWith({ action: "followUp" });
|
||||
});
|
||||
|
||||
it("handles onSuccess with action and params", async () => {
|
||||
const executeActionFn = vi.fn();
|
||||
|
||||
await executeAction({
|
||||
action: {
|
||||
action: "save",
|
||||
params: {},
|
||||
onSuccess: { action: "toast", params: { message: "Saved" } },
|
||||
},
|
||||
handler: vi.fn().mockResolvedValue(undefined),
|
||||
setState: vi.fn(),
|
||||
executeAction: executeActionFn,
|
||||
});
|
||||
|
||||
expect(executeActionFn).toHaveBeenCalledWith({
|
||||
action: "toast",
|
||||
params: { message: "Saved" },
|
||||
});
|
||||
});
|
||||
|
||||
it("handles onError with set", async () => {
|
||||
@@ -196,7 +236,28 @@ describe("executeAction", () => {
|
||||
executeAction: executeActionFn,
|
||||
});
|
||||
|
||||
expect(executeActionFn).toHaveBeenCalledWith("handleError");
|
||||
expect(executeActionFn).toHaveBeenCalledWith({ action: "handleError" });
|
||||
});
|
||||
|
||||
it("handles onError with action and params", async () => {
|
||||
const executeActionFn = vi.fn();
|
||||
const error = new Error("Failed");
|
||||
|
||||
await executeAction({
|
||||
action: {
|
||||
action: "save",
|
||||
params: {},
|
||||
onError: { action: "toast", params: { message: "Save failed" } },
|
||||
},
|
||||
handler: vi.fn().mockRejectedValue(error),
|
||||
setState: vi.fn(),
|
||||
executeAction: executeActionFn,
|
||||
});
|
||||
|
||||
expect(executeActionFn).toHaveBeenCalledWith({
|
||||
action: "toast",
|
||||
params: { message: "Save failed" },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws error when no onError handler", async () => {
|
||||
|
||||
@@ -19,14 +19,14 @@ export interface ActionConfirm {
|
||||
export type ActionOnSuccess =
|
||||
| { navigate: string }
|
||||
| { set: Record<string, unknown> }
|
||||
| { action: string };
|
||||
| { action: string; params?: Record<string, DynamicValue> };
|
||||
|
||||
/**
|
||||
* Action error handler
|
||||
*/
|
||||
export type ActionOnError =
|
||||
| { set: Record<string, unknown> }
|
||||
| { action: string };
|
||||
| { action: string; params?: Record<string, DynamicValue> };
|
||||
|
||||
/**
|
||||
* Action binding — maps an event to an action invocation.
|
||||
@@ -73,7 +73,10 @@ export const ActionConfirmSchema = z.object({
|
||||
export const ActionOnSuccessSchema = z.union([
|
||||
z.object({ navigate: z.string() }),
|
||||
z.object({ set: z.record(z.string(), z.unknown()) }),
|
||||
z.object({ action: z.string() }),
|
||||
z.object({
|
||||
action: z.string(),
|
||||
params: z.record(z.string(), DynamicValueSchema).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -81,7 +84,10 @@ export const ActionOnSuccessSchema = z.union([
|
||||
*/
|
||||
export const ActionOnErrorSchema = z.union([
|
||||
z.object({ set: z.record(z.string(), z.unknown()) }),
|
||||
z.object({ action: z.string() }),
|
||||
z.object({
|
||||
action: z.string(),
|
||||
params: z.record(z.string(), DynamicValueSchema).optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -190,7 +196,7 @@ export interface ActionExecutionContext {
|
||||
/** Function to navigate */
|
||||
navigate?: (path: string) => void;
|
||||
/** Function to execute another action */
|
||||
executeAction?: (name: string) => Promise<void>;
|
||||
executeAction?: (binding: ActionBinding) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,7 +219,7 @@ export async function executeAction(
|
||||
setState(path, value);
|
||||
}
|
||||
} else if ("action" in action.onSuccess && executeAction) {
|
||||
await executeAction(action.onSuccess.action);
|
||||
await executeAction(action.onSuccess);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -229,7 +235,7 @@ export async function executeAction(
|
||||
setState(path, resolvedValue);
|
||||
}
|
||||
} else if ("action" in action.onError && executeAction) {
|
||||
await executeAction(action.onError.action);
|
||||
await executeAction(action.onError);
|
||||
}
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
DynamicString,
|
||||
DynamicNumber,
|
||||
DynamicBoolean,
|
||||
RepeatStatePath,
|
||||
UIElement,
|
||||
FlatElement,
|
||||
Spec,
|
||||
@@ -38,6 +39,8 @@ export {
|
||||
DynamicBooleanSchema,
|
||||
resolveDynamicValue,
|
||||
getByPath,
|
||||
resolveRepeatStatePath,
|
||||
resolveRepeatItemStatePath,
|
||||
setByPath,
|
||||
addByPath,
|
||||
removeByPath,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Schema, SchemaType } from "./schema";
|
||||
import { schema as imageSchema } from "../../image/src/schema";
|
||||
import { schema as inkSchema } from "../../ink/src/schema";
|
||||
import { schema as reactEmailSchema } from "../../react-email/src/schema";
|
||||
import { schema as reactNativeSchema } from "../../react-native/src/schema";
|
||||
import { schema as reactPdfSchema } from "../../react-pdf/src/schema";
|
||||
import { schema as reactSchema } from "../../react/src/schema";
|
||||
import { schema as solidSchema } from "../../solid/src/schema";
|
||||
import { schema as svelteSchema } from "../../svelte/src/schema";
|
||||
import { schema as vueSchema } from "../../vue/src/schema";
|
||||
|
||||
const schemas: Record<string, Schema> = {
|
||||
image: imageSchema,
|
||||
ink: inkSchema,
|
||||
react: reactSchema,
|
||||
"react-email": reactEmailSchema,
|
||||
"react-native": reactNativeSchema,
|
||||
"react-pdf": reactPdfSchema,
|
||||
solid: solidSchema,
|
||||
svelte: svelteSchema,
|
||||
vue: vueSchema,
|
||||
};
|
||||
|
||||
describe("renderer schema repeat parity", () => {
|
||||
it.each(Object.entries(schemas))(
|
||||
"%s declares repeat as an optional element field",
|
||||
(_name, schema) => {
|
||||
const spec = schema.definition.spec as SchemaType<
|
||||
"object",
|
||||
Record<string, SchemaType>
|
||||
>;
|
||||
const elements = spec.inner?.elements as SchemaType<
|
||||
"record",
|
||||
SchemaType<"object", Record<string, SchemaType>>
|
||||
>;
|
||||
const repeat = elements.inner?.inner?.repeat;
|
||||
|
||||
expect(repeat?.kind).toBe("any");
|
||||
expect(repeat?.optional).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
@@ -762,6 +777,9 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
lines.push(
|
||||
'The element itself renders once (as the container), and its children are expanded once per array item. "statePath" is the state array path. "key" is an optional field name on each item for stable React keys.',
|
||||
);
|
||||
lines.push(
|
||||
'For nested lists, an inner repeat can read an array from the enclosing item with { "statePath": { "$item": "field" } }. This form is valid only inside another repeat. Use an empty field to repeat over the enclosing item itself.',
|
||||
);
|
||||
lines.push(
|
||||
`Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { statePath: "/todos", key: "id" }, children: ["todo-item"] })}`,
|
||||
);
|
||||
@@ -809,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);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
@@ -236,6 +275,231 @@ describe("repeat validation", () => {
|
||||
});
|
||||
expect(runtimeState.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a nested repeat relative to the enclosing item", () => {
|
||||
const result = validateSpec({
|
||||
root: "groups",
|
||||
state: {
|
||||
groups: [{ subitems: [{ label: "a" }] }],
|
||||
},
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["subitems"],
|
||||
},
|
||||
subitems: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "subitems" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rejects a relative repeat outside repeat scope", () => {
|
||||
const result = validateSpec({
|
||||
root: "items",
|
||||
state: { items: [{ label: "root" }] },
|
||||
elements: {
|
||||
items: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "items" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.issues.some((issue) => issue.code === "repeat_item_outside_scope"),
|
||||
).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",
|
||||
state: { groups: [] },
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["subitems"],
|
||||
},
|
||||
subitems: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "subitems" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a nested relative repeat that does not resolve to an array", () => {
|
||||
const result = validateSpec({
|
||||
root: "groups",
|
||||
state: { groups: [{ subitems: { label: "a" } }] },
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["subitems"],
|
||||
},
|
||||
subitems: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "/subitems" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.issues.some((issue) => issue.code === "repeat_state_mismatch"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not duplicate repeat issues for the same structural context", () => {
|
||||
const result = validateSpec({
|
||||
root: "root",
|
||||
state: { items: [] },
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["left", "right"],
|
||||
},
|
||||
left: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["shared"],
|
||||
},
|
||||
right: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["shared"],
|
||||
},
|
||||
shared: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "items" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.issues.filter(
|
||||
(issue) => issue.code === "repeat_item_outside_scope",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("validates a reused repeat separately inside and outside scope", () => {
|
||||
const result = validateSpec({
|
||||
root: "root",
|
||||
state: { groups: [{ items: [] }] },
|
||||
elements: {
|
||||
root: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
children: ["groups", "shared"],
|
||||
},
|
||||
groups: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["shared"],
|
||||
},
|
||||
shared: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "items" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.issues.filter(
|
||||
(issue) => issue.code === "repeat_item_outside_scope",
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("terminates repeat validation for cyclic child graphs", () => {
|
||||
const result = validateSpec({
|
||||
root: "groups",
|
||||
state: { groups: [{ items: [] }] },
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["items"],
|
||||
},
|
||||
items: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "items" } },
|
||||
children: ["groups"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.issues.filter((issue) => issue.code === "repeat_state_mismatch"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visible condition validation", () => {
|
||||
@@ -322,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",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Spec, UIElement } from "./types";
|
||||
import { getByPath } from "./types";
|
||||
import {
|
||||
getByPath,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
} from "./types";
|
||||
import { VisibilityConditionStrictSchema } from "./visibility";
|
||||
|
||||
// =============================================================================
|
||||
@@ -28,6 +32,7 @@ export interface SpecIssue {
|
||||
| "missing_child"
|
||||
| "invalid_visible"
|
||||
| "repeat_without_children"
|
||||
| "repeat_item_outside_scope"
|
||||
| "repeat_state_mismatch"
|
||||
| "visible_in_props"
|
||||
| "orphaned_element"
|
||||
@@ -126,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.
|
||||
@@ -138,17 +157,6 @@ export function validateSpec(
|
||||
code: "repeat_without_children",
|
||||
});
|
||||
}
|
||||
if (spec.state !== undefined) {
|
||||
const value = getByPath(spec.state, element.repeat.statePath);
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" repeats over "${element.repeat.statePath}" but state${value === undefined ? " has no value there" : ` has a ${typeof value} there`}. Repeat statePath must reference an array in state; add sample items to state at that path.`,
|
||||
elementKey: key,
|
||||
code: "repeat_state_mismatch",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Malformed visible condition. Unrecognized shapes silently evaluate
|
||||
@@ -207,6 +215,98 @@ export function validateSpec(
|
||||
}
|
||||
}
|
||||
|
||||
const repeatValidatedKeys = new Set<string>();
|
||||
const repeatValidatedContexts = new Set<string>();
|
||||
const validateRepeatPaths = (
|
||||
key: string,
|
||||
repeatBasePath: string | undefined,
|
||||
sampleAvailable: boolean,
|
||||
ancestors: Set<string>,
|
||||
) => {
|
||||
if (ancestors.has(key)) return;
|
||||
const element = spec.elements[key];
|
||||
if (!element) return;
|
||||
repeatValidatedKeys.add(key);
|
||||
const contextKey = `${key}\u0000${repeatBasePath ?? ""}\u0000${sampleAvailable}`;
|
||||
if (repeatValidatedContexts.has(contextKey)) return;
|
||||
repeatValidatedContexts.add(contextKey);
|
||||
|
||||
let childRepeatBasePath = repeatBasePath;
|
||||
let childSampleAvailable = sampleAvailable;
|
||||
|
||||
if (element.repeat !== undefined) {
|
||||
const statePath = resolveRepeatStatePath(
|
||||
element.repeat.statePath,
|
||||
repeatBasePath,
|
||||
);
|
||||
const displayPath =
|
||||
typeof element.repeat.statePath === "string"
|
||||
? element.repeat.statePath
|
||||
: JSON.stringify(element.repeat.statePath);
|
||||
|
||||
if (statePath === undefined) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" uses relative repeat statePath ${displayPath} outside a repeat scope.`,
|
||||
elementKey: key,
|
||||
code: "repeat_item_outside_scope",
|
||||
});
|
||||
childRepeatBasePath = undefined;
|
||||
childSampleAvailable = false;
|
||||
} else {
|
||||
const canCheckSample =
|
||||
spec.state !== undefined &&
|
||||
(typeof element.repeat.statePath === "string" || sampleAvailable);
|
||||
const value = canCheckSample
|
||||
? getByPath(spec.state, statePath)
|
||||
: undefined;
|
||||
|
||||
if (canCheckSample && !Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" repeats over "${statePath}" but state${value === undefined ? " has no value there" : ` has a ${typeof value} there`}. Repeat statePath must reference an array in state; add sample items to state at that path.`,
|
||||
elementKey: key,
|
||||
code: "repeat_state_mismatch",
|
||||
});
|
||||
}
|
||||
|
||||
childRepeatBasePath = resolveRepeatItemStatePath(statePath, 0);
|
||||
childSampleAvailable =
|
||||
canCheckSample && Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
const nextAncestors = new Set(ancestors);
|
||||
nextAncestors.add(key);
|
||||
for (const childKey of element.children ?? []) {
|
||||
validateRepeatPaths(
|
||||
childKey,
|
||||
childRepeatBasePath,
|
||||
childSampleAvailable,
|
||||
nextAncestors,
|
||||
);
|
||||
}
|
||||
for (const childKeys of Object.values(element.slots ?? {})) {
|
||||
for (const childKey of childKeys) {
|
||||
validateRepeatPaths(
|
||||
childKey,
|
||||
repeatBasePath,
|
||||
sampleAvailable,
|
||||
nextAncestors,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (spec.elements[spec.root]) {
|
||||
validateRepeatPaths(spec.root, undefined, true, new Set());
|
||||
}
|
||||
for (const key of Object.keys(spec.elements)) {
|
||||
if (!repeatValidatedKeys.has(key)) {
|
||||
validateRepeatPaths(key, undefined, true, new Set());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Orphaned elements (optional)
|
||||
if (checkOrphans) {
|
||||
const reachable = new Set<string>();
|
||||
@@ -221,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);
|
||||
@@ -378,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),
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
resolveDynamicValue,
|
||||
getByPath,
|
||||
resolveRepeatStatePath,
|
||||
resolveRepeatItemStatePath,
|
||||
setByPath,
|
||||
addByPath,
|
||||
removeByPath,
|
||||
@@ -58,6 +60,41 @@ describe("getByPath", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRepeatStatePath", () => {
|
||||
it("preserves string paths exactly", () => {
|
||||
expect(resolveRepeatStatePath("/items")).toBe("/items");
|
||||
expect(resolveRepeatStatePath("items")).toBe("items");
|
||||
});
|
||||
|
||||
it("resolves $item paths against the enclosing item path", () => {
|
||||
expect(resolveRepeatStatePath({ $item: "subitems" }, "/groups/0")).toBe(
|
||||
"/groups/0/subitems",
|
||||
);
|
||||
expect(resolveRepeatStatePath({ $item: "/subitems" }, "/groups/0")).toBe(
|
||||
"/groups/0/subitems",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an empty $item path to the enclosing item", () => {
|
||||
expect(resolveRepeatStatePath({ $item: "" }, "/groups/0")).toBe(
|
||||
"/groups/0",
|
||||
);
|
||||
expect(resolveRepeatStatePath({ $item: "/" }, "/groups/0")).toBe(
|
||||
"/groups/0",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not resolve $item outside repeat scope", () => {
|
||||
expect(resolveRepeatStatePath({ $item: "items" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds item paths without duplicate root separators", () => {
|
||||
expect(resolveRepeatItemStatePath("/items", 2)).toBe("/items/2");
|
||||
expect(resolveRepeatItemStatePath("/", 2)).toBe("/2");
|
||||
expect(resolveRepeatItemStatePath("items", 2)).toBe("items/2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setByPath", () => {
|
||||
it("sets value at existing path", () => {
|
||||
const data: Record<string, unknown> = { user: { name: "John" } };
|
||||
@@ -789,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",
|
||||
|
||||
@@ -50,6 +50,8 @@ export const DynamicBooleanSchema = z.union([
|
||||
z.object({ $state: z.string() }),
|
||||
]);
|
||||
|
||||
export type RepeatStatePath = string | { $item: string };
|
||||
|
||||
/**
|
||||
* Base UI element structure for v2
|
||||
*/
|
||||
@@ -63,12 +65,13 @@ 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 */
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
/** Repeat children once per item in a state array */
|
||||
repeat?: { statePath: string; key?: string };
|
||||
repeat?: { statePath: RepeatStatePath; key?: string };
|
||||
/**
|
||||
* State watchers — maps JSON Pointer state paths to action bindings.
|
||||
* When the value at a watched path changes, the bound actions fire.
|
||||
@@ -307,6 +310,40 @@ export function getByPath(obj: unknown, path: string): unknown {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function resolveRepeatStatePath(
|
||||
statePath: RepeatStatePath,
|
||||
repeatBasePath?: string | null,
|
||||
): string | undefined {
|
||||
if (typeof statePath === "string") {
|
||||
return statePath;
|
||||
}
|
||||
|
||||
if (repeatBasePath == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (statePath.$item === "" || statePath.$item === "/") {
|
||||
return repeatBasePath;
|
||||
}
|
||||
|
||||
return joinStatePath(repeatBasePath, statePath.$item);
|
||||
}
|
||||
|
||||
export function resolveRepeatItemStatePath(
|
||||
statePath: string,
|
||||
index: number,
|
||||
): string {
|
||||
return joinStatePath(statePath, String(index));
|
||||
}
|
||||
|
||||
function joinStatePath(basePath: string, childPath: string): string {
|
||||
const child = childPath.startsWith("/") ? childPath.slice(1) : childPath;
|
||||
if (basePath === "" || basePath === "/") {
|
||||
return `/${child}`;
|
||||
}
|
||||
return `${basePath}/${child}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a numeric index
|
||||
*/
|
||||
@@ -649,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;
|
||||
}
|
||||
@@ -691,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[] = [];
|
||||
@@ -703,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-react",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-solid",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "SolidJS adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-svelte",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Svelte adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools-vue",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue adapter for @json-render/devtools. Drop-in <JsonRenderDevtools /> component.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/devtools",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Framework-agnostic devtools core for json-render: event store, panel UI, picker, stream taps.",
|
||||
"keywords": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/directives",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Pre-built directives for @json-render/core — $format, $math, $concat, $count, $truncate, $pluralize, $join, and $t (i18n).",
|
||||
"keywords": [
|
||||
|
||||
@@ -156,6 +156,8 @@ const png = await renderToPng(spec, { fonts });
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
Import schema and catalog definitions without pulling in React or Satori:
|
||||
|
||||
```typescript
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/image",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
|
||||
"keywords": [
|
||||
|
||||
@@ -3,6 +3,8 @@ import satori, { type SatoriOptions } from "satori";
|
||||
import type { Spec, UIElement } from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -60,21 +62,25 @@ function renderElement(
|
||||
if (!Component) return null;
|
||||
|
||||
if (resolvedElement.repeat) {
|
||||
const repeat = resolvedElement.repeat;
|
||||
const statePath = resolveRepeatStatePath(repeat.statePath, repeatBasePath);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/image] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items =
|
||||
(getByPath(stateModel, resolvedElement.repeat.statePath) as
|
||||
| unknown[]
|
||||
| undefined) ?? [];
|
||||
(getByPath(stateModel, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
const fragments = items.map((item, index) => {
|
||||
const key =
|
||||
resolvedElement.repeat!.key && typeof item === "object" && item !== null
|
||||
? String(
|
||||
(item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
|
||||
index,
|
||||
)
|
||||
repeat.key && typeof item === "object" && item !== null
|
||||
? String((item as Record<string, unknown>)[repeat.key!] ?? index)
|
||||
: String(index);
|
||||
|
||||
const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
|
||||
const childPath = resolveRepeatItemStatePath(statePath, index);
|
||||
const children = resolvedElement.children?.map((childKey) =>
|
||||
renderElement(
|
||||
childKey,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const schema = defineSchema(
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -110,6 +110,8 @@ const { spec, send, isStreaming } = useUIStream({ api: "/api/generate" });
|
||||
|
||||
## Key Exports
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `createRenderer` | Create an all-in-one renderer component from a catalog |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/ink",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
|
||||
"keywords": [
|
||||
|
||||
@@ -280,9 +280,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: navigateRef.current,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -320,8 +322,18 @@ function RepeatChildren({
|
||||
fallback?: ComponentRenderer;
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const parentScope = useRepeatScope();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/ink] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = getByPath(state, statePath);
|
||||
const items = Array.isArray(raw) ? raw : [];
|
||||
@@ -341,7 +353,7 @@ function RepeatChildren({
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath, index)}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
|
||||
@@ -23,6 +23,8 @@ export const schema = defineSchema(
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -76,7 +78,7 @@ export const schema = defineSchema(
|
||||
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. logs, tasks, metrics), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array, use the "repeat" field on a container element. Example: { "type": "Box", "props": { "flexDirection": "column" }, "repeat": { "statePath": "/items", "key": "id" }, "children": ["item-row"] }. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index.',
|
||||
'When building repeating content backed by a state array, use the "repeat" field on a container element. Example: { "type": "Box", "props": { "flexDirection": "column" }, "repeat": { "statePath": "/items", "key": "id" }, "children": ["item-row"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "children" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Inside repeated children, use { "$item": "field" } to read from the current item and { "$index": true } for the current index.',
|
||||
// Terminal UI design
|
||||
"This UI renders in a terminal using Ink. Use Box for layout (flexDirection, padding, gap), Text for text content. Keep designs compact and readable in monospace.",
|
||||
"Terminal UIs have limited width (~80-120 columns). Prefer vertical layouts (flexDirection: column) for main structure. Use horizontal layouts (flexDirection: row) for inline elements like badges, key-value pairs, and table rows.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/mcp",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/next",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
|
||||
"keywords": [
|
||||
|
||||
@@ -128,6 +128,8 @@ Both accept an optional second argument with:
|
||||
- `includeStandard` — Include built-in standard components (default: `true`)
|
||||
- `state` — Initial state for `$state` / `$cond` dynamic prop resolution
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React or `@react-email/components`:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-email",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
|
||||
"keywords": [
|
||||
|
||||
@@ -174,9 +174,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -196,9 +195,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { render } from "@react-email/render";
|
||||
import type { Spec, UIElement } from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -57,12 +59,18 @@ function renderElement(
|
||||
if (!Component) return null;
|
||||
|
||||
if (resolvedElement.repeat) {
|
||||
const repeat = resolvedElement.repeat;
|
||||
const statePath = resolveRepeatStatePath(repeat.statePath, repeatBasePath);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/react-email] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items =
|
||||
(getByPath(stateModel, resolvedElement.repeat.statePath) as
|
||||
| unknown[]
|
||||
| undefined) ?? [];
|
||||
(getByPath(stateModel, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
const repeat = resolvedElement.repeat!;
|
||||
const fragments = items.map((item, index) => {
|
||||
const repeatKey = repeat.key;
|
||||
const key =
|
||||
@@ -70,7 +78,7 @@ function renderElement(
|
||||
? String((item as Record<string, unknown>)[repeatKey] ?? index)
|
||||
: String(index);
|
||||
|
||||
const childPath = `${repeat.statePath}/${index}`;
|
||||
const childPath = resolveRepeatItemStatePath(statePath, index);
|
||||
const children = resolvedElement.children?.map((childKey) =>
|
||||
renderElement(
|
||||
childKey,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -248,8 +250,18 @@ function RepeatChildren({
|
||||
fallback?: ComponentRenderer;
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const parentScope = useRepeatScope();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/react-email] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
@@ -268,7 +280,7 @@ function RepeatChildren({
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath, index)}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
|
||||
@@ -19,6 +19,7 @@ export const schema = defineSchema(
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -236,6 +236,8 @@ When `store` is provided, `initialState` and `onStateChange` are ignored. The st
|
||||
|
||||
## Hooks
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|---------|
|
||||
| `useStateStore()` | Access state context (`state`, `get`, `set`, `update`) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -262,9 +262,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -285,9 +284,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -300,8 +302,18 @@ function RepeatChildren({
|
||||
fallback?: ComponentRenderer;
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const parentScope = useRepeatScope();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/react-native] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
@@ -321,7 +333,7 @@ function RepeatChildren({
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath, index)}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
|
||||
@@ -24,6 +24,8 @@ export const schema = defineSchema(
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -60,7 +62,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.',
|
||||
'When building repeating content backed by a state array (e.g. todos, posts, cart items), use the "repeat" field on a container element from the AVAILABLE COMPONENTS list. Example: { "type": "<ContainerComponent>", "props": { "gap": 8 }, "repeat": { "statePath": "/todos", "key": "id" }, "children": ["todo-item"] }. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
'When building repeating content backed by a state array (e.g. todos, posts, cart items), use the "repeat" field on a container element from the AVAILABLE COMPONENTS list. Example: { "type": "<ContainerComponent>", "props": { "gap": 8 }, "repeat": { "statePath": "/todos", "key": "id" }, "children": ["todo-item"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
|
||||
// Visible field placement
|
||||
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/activeTab","eq":"home"},"children":[...]}. WRONG: {"type":"<ComponentName>","props":{},"visible":{...},"children":[...]} with visible inside props.',
|
||||
|
||||
@@ -154,6 +154,8 @@ All render functions accept an optional second argument with:
|
||||
- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
|
||||
- `handlers` - Action handlers
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
## External Store (Controlled Mode)
|
||||
|
||||
For full control over state, pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer`. When `store` is provided, `initialState` and `onStateChange` are ignored and the store is the single source of truth:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
|
||||
@@ -176,9 +176,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -198,9 +197,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
import type { Spec, UIElement } from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -61,21 +63,25 @@ function renderElement(
|
||||
if (!Component) return null;
|
||||
|
||||
if (resolvedElement.repeat) {
|
||||
const repeat = resolvedElement.repeat;
|
||||
const statePath = resolveRepeatStatePath(repeat.statePath, repeatBasePath);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/react-pdf] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items =
|
||||
(getByPath(stateModel, resolvedElement.repeat.statePath) as
|
||||
| unknown[]
|
||||
| undefined) ?? [];
|
||||
(getByPath(stateModel, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
const fragments = items.map((item, index) => {
|
||||
const key =
|
||||
resolvedElement.repeat!.key && typeof item === "object" && item !== null
|
||||
? String(
|
||||
(item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
|
||||
index,
|
||||
)
|
||||
repeat.key && typeof item === "object" && item !== null
|
||||
? String((item as Record<string, unknown>)[repeat.key!] ?? index)
|
||||
: String(index);
|
||||
|
||||
const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
|
||||
const childPath = resolveRepeatItemStatePath(statePath, index);
|
||||
const children = resolvedElement.children?.map((childKey) =>
|
||||
renderElement(
|
||||
childKey,
|
||||
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
@@ -256,8 +258,18 @@ function RepeatChildren({
|
||||
fallback?: ComponentRenderer;
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const parentScope = useRepeatScope();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/react-pdf] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
@@ -276,7 +288,7 @@ function RepeatChildren({
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath, index)}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
|
||||
@@ -19,6 +19,7 @@ export const schema = defineSchema(
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-three-fiber",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Three Fiber renderer for @json-render/core. JSON becomes 3D scenes.",
|
||||
"keywords": [
|
||||
|
||||
+88
-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
|
||||
@@ -519,27 +537,29 @@ function App() {
|
||||
|
||||
## 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 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` |
|
||||
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` |
|
||||
|
||||
### 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -195,4 +195,46 @@ describe("chained actions: live $state resolution (#141)", () => {
|
||||
expect(state.counter).toBe(42);
|
||||
expect(state.counterCopy).toBe(42);
|
||||
});
|
||||
|
||||
it("forwards params to a named onSuccess action (#301)", async () => {
|
||||
let receivedParams: Record<string, unknown> | undefined;
|
||||
const handlers = {
|
||||
save: async () => {},
|
||||
toast: async (params: Record<string, unknown>) => {
|
||||
receivedParams = params;
|
||||
},
|
||||
};
|
||||
|
||||
const spec: Spec = {
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Button",
|
||||
props: { label: "Save" },
|
||||
on: {
|
||||
press: {
|
||||
action: "save",
|
||||
onSuccess: { action: "toast", params: { message: "Saved!" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<JSONUIProvider registry={registry} handlers={handlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<App />);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByTestId("btn"));
|
||||
});
|
||||
|
||||
expect(receivedParams).toEqual({ message: "Saved!" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -309,9 +309,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -332,9 +331,8 @@ export function ActionProvider({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -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,6 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { Renderer } from "./renderer";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
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", () => {
|
||||
@@ -40,4 +49,152 @@ 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) => {
|
||||
function Group({ children }: ComponentRenderProps) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
|
||||
return (
|
||||
<span data-testid="item-text">{String(element.props.text)}</span>
|
||||
);
|
||||
}
|
||||
|
||||
const spec: Spec = {
|
||||
root: "groups",
|
||||
state: {
|
||||
groups: [
|
||||
{ subitems: [{ label: "a1" }, { label: "a2" }] },
|
||||
{ subitems: [{ label: "b1" }] },
|
||||
],
|
||||
},
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Group",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["subitems"],
|
||||
},
|
||||
subitems: {
|
||||
type: "Group",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: itemPath } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "label" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
render(
|
||||
<JSONUIProvider registry={{ Group, Text }} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={{ Group, Text }} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getAllByTestId("item-text").map((el) => el.textContent),
|
||||
).toEqual(["a1", "a2", "b1"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fall back to root state for $item outside repeat scope", () => {
|
||||
function Group({ children }: ComponentRenderProps) {
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
|
||||
return <span data-testid="item-text">{String(element.props.text)}</span>;
|
||||
}
|
||||
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const spec: Spec = {
|
||||
root: "items",
|
||||
state: { items: [{ label: "must-not-render" }] },
|
||||
elements: {
|
||||
items: {
|
||||
type: "Group",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "items" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "label" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { queryAllByTestId } = render(
|
||||
<JSONUIProvider registry={{ Group, Text }} initialState={spec.state}>
|
||||
<Renderer spec={spec} registry={{ Group, Text }} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
|
||||
expect(queryAllByTestId("item-text")).toHaveLength(0);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[json-render] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
splitRepeatVisibility,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
@@ -60,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. */
|
||||
@@ -86,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
|
||||
*/
|
||||
@@ -395,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;
|
||||
@@ -427,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}
|
||||
@@ -483,8 +522,18 @@ function RepeatChildren({
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const { ctx } = useVisibility();
|
||||
const parentScope = useRepeatScope();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
@@ -519,7 +568,7 @@ function RepeatChildren({
|
||||
key={key}
|
||||
item={itemValue}
|
||||
index={index}
|
||||
basePath={`${statePath}/${index}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath, index)}
|
||||
>
|
||||
{element.children?.map((childKey) => {
|
||||
const childElement = spec.elements[childKey];
|
||||
@@ -736,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
|
||||
@@ -746,6 +795,7 @@ export function defineRegistry<C extends Catalog>(
|
||||
registry[name] = ({
|
||||
element,
|
||||
children,
|
||||
slots,
|
||||
emit,
|
||||
on,
|
||||
bindings,
|
||||
@@ -754,6 +804,7 @@ export function defineRegistry<C extends Catalog>(
|
||||
return (componentFn as DefineRegistryComponentFn)({
|
||||
props: element.props,
|
||||
children,
|
||||
slots,
|
||||
emit,
|
||||
on,
|
||||
bindings,
|
||||
@@ -762,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
|
||||
@@ -811,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>;
|
||||
@@ -894,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,8 +22,11 @@ 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 */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -78,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
|
||||
@@ -86,7 +90,7 @@ export const schema = defineSchema(
|
||||
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
|
||||
// Design quality
|
||||
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn-svelte",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn-svelte component library for @json-render/svelte. JSON becomes beautiful Tailwind-styled Svelte components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -227,6 +227,8 @@ await stream.send("Build me a dashboard");
|
||||
|
||||
## Differences from `@json-render/react`
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
Most APIs are intentionally aligned, but there are runtime behavior differences due to Solid:
|
||||
|
||||
- Solid components run once, then update via signals.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/solid",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -239,9 +239,8 @@ export function ActionProvider(props: ParentProps<ActionProviderProps>) {
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name: string) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -261,9 +260,8 @@ export function ActionProvider(props: ParentProps<ActionProviderProps>) {
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name: string) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
isDevtoolsActive,
|
||||
@@ -427,10 +429,25 @@ interface RepeatChildrenProps {
|
||||
function RepeatChildren(props: RepeatChildrenProps) {
|
||||
const stateStore = useStateStore();
|
||||
const repeat = () => props.element.repeat!;
|
||||
const statePath = () => repeat().statePath;
|
||||
const parentScope = useRepeatScope();
|
||||
const statePath = () => {
|
||||
const resolved = resolveRepeatStatePath(
|
||||
repeat().statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (resolved === undefined) {
|
||||
console.warn(
|
||||
"[json-render/solid] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const items = () =>
|
||||
(getByPath(stateStore.state, statePath()) as unknown[] | undefined) ?? [];
|
||||
statePath() === undefined
|
||||
? []
|
||||
: ((getByPath(stateStore.state, statePath()!) as unknown[] | undefined) ??
|
||||
[]);
|
||||
|
||||
return (
|
||||
<For each={items()}>
|
||||
@@ -446,7 +463,7 @@ function RepeatChildren(props: RepeatChildrenProps) {
|
||||
<RepeatScopeProvider
|
||||
item={itemValue}
|
||||
index={index()}
|
||||
basePath={`${statePath()}/${index()}`}
|
||||
basePath={resolveRepeatItemStatePath(statePath()!, index())}
|
||||
>
|
||||
<For each={props.element.children ?? []}>
|
||||
{(childKey) => {
|
||||
|
||||
@@ -24,6 +24,8 @@ export const schema = defineSchema(
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -85,7 +87,7 @@ export const schema = defineSchema(
|
||||
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
|
||||
// Design quality
|
||||
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||
|
||||
@@ -148,6 +148,8 @@ const chat = createChatUI({ endpoint: "/api/chat" });
|
||||
|
||||
## Documentation
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
Full API reference: [json-render.dev/docs/api/svelte](https://json-render.dev/docs/api/svelte).
|
||||
|
||||
## License
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/svelte",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Svelte 5 renderer for @json-render/core. JSON becomes Svelte components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script lang="ts">
|
||||
import type { Spec, UIElement } from "@json-render/core";
|
||||
import { getByPath } from "@json-render/core";
|
||||
import {
|
||||
getByPath,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
} from "@json-render/core";
|
||||
import type { ComponentRegistry, ComponentRenderer } from "./renderer.js";
|
||||
import { getStateContext } from "./contexts/StateProvider.svelte";
|
||||
import RepeatScopeProvider from "./contexts/RepeatScopeProvider.svelte";
|
||||
import RepeatScopeProvider, {
|
||||
getRepeatScope,
|
||||
} from "./contexts/RepeatScopeProvider.svelte";
|
||||
import ElementRenderer from "./ElementRenderer.svelte";
|
||||
|
||||
interface Props {
|
||||
@@ -17,17 +23,30 @@
|
||||
let { element, spec, registry, loading = false, fallback }: Props = $props();
|
||||
|
||||
const stateCtx = getStateContext();
|
||||
const parentScope = getRepeatScope();
|
||||
|
||||
let statePath = $derived.by(() => {
|
||||
const resolved = resolveRepeatStatePath(
|
||||
element.repeat!.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (resolved === undefined) {
|
||||
console.warn(
|
||||
"[json-render/svelte] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
}
|
||||
return resolved;
|
||||
});
|
||||
|
||||
// Get items from state
|
||||
let items = $derived(
|
||||
(getByPath(stateCtx.state, element.repeat!.statePath) as
|
||||
| unknown[]
|
||||
| undefined) ?? [],
|
||||
statePath === undefined
|
||||
? []
|
||||
: ((getByPath(stateCtx.state, statePath) as unknown[] | undefined) ?? []),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#each items as itemValue, index (element.repeat?.key && typeof itemValue === "object" && itemValue !== null ? String((itemValue as any)[element.repeat.key] ?? index) : String(index))}
|
||||
{@const basePath = `${element.repeat!.statePath}/${index}`}
|
||||
{@const basePath = resolveRepeatItemStatePath(statePath!, index)}
|
||||
|
||||
{#if element.children}
|
||||
<RepeatScopeProvider item={itemValue} {index} {basePath}>
|
||||
|
||||
@@ -304,9 +304,8 @@
|
||||
handler,
|
||||
setState: stateCtx.set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: CoreActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -322,9 +321,8 @@
|
||||
handler,
|
||||
setState: stateCtx.set,
|
||||
navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: CoreActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -179,6 +179,56 @@ describe("createActionContext", () => {
|
||||
})(),
|
||||
);
|
||||
|
||||
it(
|
||||
"forwards params to a named onSuccess action (#301)",
|
||||
(() => {
|
||||
const toast = vi.fn().mockResolvedValue(undefined);
|
||||
return component(
|
||||
async () => {
|
||||
const actionCtx = getActionContext();
|
||||
|
||||
await actionCtx.execute({
|
||||
action: "save",
|
||||
onSuccess: { action: "toast", params: { message: "Saved" } },
|
||||
});
|
||||
|
||||
expect(toast).toHaveBeenCalledWith({ message: "Saved" });
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
toast,
|
||||
},
|
||||
},
|
||||
);
|
||||
})(),
|
||||
);
|
||||
|
||||
it(
|
||||
"forwards params to a named onError action (#301)",
|
||||
(() => {
|
||||
const toast = vi.fn().mockResolvedValue(undefined);
|
||||
return component(
|
||||
async () => {
|
||||
const actionCtx = getActionContext();
|
||||
|
||||
await actionCtx.execute({
|
||||
action: "save",
|
||||
onError: { action: "toast", params: { message: "Failed" } },
|
||||
});
|
||||
|
||||
expect(toast).toHaveBeenCalledWith({ message: "Failed" });
|
||||
},
|
||||
{
|
||||
handlers: {
|
||||
save: vi.fn().mockRejectedValue(new Error("boom")),
|
||||
toast,
|
||||
},
|
||||
},
|
||||
);
|
||||
})(),
|
||||
);
|
||||
|
||||
it(
|
||||
"warns when no handler registered",
|
||||
component(async () => {
|
||||
|
||||
@@ -191,4 +191,42 @@ describe("Renderer", () => {
|
||||
expect(texts).toHaveLength(1);
|
||||
expect(texts[0]?.textContent).toBe("I exist");
|
||||
});
|
||||
|
||||
it("renders nested repeats from the enclosing item", () => {
|
||||
const spec: Spec = {
|
||||
root: "groups",
|
||||
state: {
|
||||
groups: [
|
||||
{ subitems: [{ label: "a1" }, { label: "a2" }] },
|
||||
{ subitems: [{ label: "b1" }] },
|
||||
],
|
||||
},
|
||||
elements: {
|
||||
groups: {
|
||||
type: "Container",
|
||||
props: {},
|
||||
repeat: { statePath: "/groups" },
|
||||
children: ["subitems"],
|
||||
},
|
||||
subitems: {
|
||||
type: "Container",
|
||||
props: {},
|
||||
repeat: { statePath: { $item: "/subitems" } },
|
||||
children: ["label"],
|
||||
},
|
||||
label: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "label" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = mountRenderer(spec);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll(".test-text")).map(
|
||||
(element) => element.textContent,
|
||||
),
|
||||
).toEqual(["a1", "a2", "b1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,8 @@ export const schema = defineSchema(
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -85,7 +87,7 @@ export const schema = defineSchema(
|
||||
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
|
||||
// Design quality
|
||||
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||
|
||||
@@ -488,6 +488,8 @@ const spec = {
|
||||
|
||||
## Differences from `@json-render/react`
|
||||
|
||||
Nested lists can set `repeat.statePath` to `{ "$item": "field" }` to iterate an array on the enclosing repeat item.
|
||||
|
||||
| API | React | Vue | Note |
|
||||
|-----|-------|-----|------|
|
||||
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -287,9 +287,8 @@ export const ActionProvider = defineComponent({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -310,9 +309,8 @@ export const ActionProvider = defineComponent({
|
||||
handler,
|
||||
setState: set,
|
||||
navigate: props.navigate,
|
||||
executeAction: async (name) => {
|
||||
const subBinding: ActionBinding = { action: name };
|
||||
await execute(subBinding);
|
||||
executeAction: async (binding) => {
|
||||
await execute(binding);
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
resolveRepeatItemStatePath,
|
||||
resolveRepeatStatePath,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
createDirectiveRegistry,
|
||||
@@ -487,11 +489,21 @@ const RepeatChildren = defineComponent({
|
||||
},
|
||||
setup(props) {
|
||||
const { state } = useStateStore();
|
||||
const parentScope = useRepeatScope();
|
||||
|
||||
return () => {
|
||||
const repeat = props.element.repeat;
|
||||
if (!repeat?.statePath) return null;
|
||||
const statePath = repeat.statePath;
|
||||
const statePath = resolveRepeatStatePath(
|
||||
repeat.statePath,
|
||||
parentScope?.basePath,
|
||||
);
|
||||
if (statePath === undefined) {
|
||||
console.warn(
|
||||
"[json-render/vue] $item in repeat.statePath used outside of a repeat scope",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const raw = getByPath(state.value, statePath);
|
||||
const items = Array.isArray(raw) ? (raw as unknown[]) : [];
|
||||
|
||||
@@ -505,7 +517,12 @@ const RepeatChildren = defineComponent({
|
||||
|
||||
return h(
|
||||
RepeatScopeProvider,
|
||||
{ key, item: itemValue, index, basePath: `${statePath}/${index}` },
|
||||
{
|
||||
key,
|
||||
item: itemValue,
|
||||
index,
|
||||
basePath: resolveRepeatItemStatePath(statePath, index),
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
props.element.children
|
||||
|
||||
@@ -24,6 +24,8 @@ export const schema = defineSchema(
|
||||
children: s.array(s.string()),
|
||||
/** Visibility condition */
|
||||
visible: { ...s.any(), ...s.optional() },
|
||||
/** Repeat children from a state array */
|
||||
repeat: { ...s.any(), ...s.optional() },
|
||||
}),
|
||||
),
|
||||
}),
|
||||
@@ -85,7 +87,7 @@ export const schema = defineSchema(
|
||||
|
||||
// State and data
|
||||
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. For a nested list stored on the enclosing item, use "repeat": { "statePath": { "$item": "comments" }, "key": "id" }. The $item statePath form is valid only inside another repeat. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
|
||||
|
||||
// Design quality
|
||||
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/xstate",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "XState Store adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/yaml",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.",
|
||||
"keywords": [
|
||||
|
||||
@@ -376,6 +376,9 @@ export function yamlPrompt(
|
||||
lines.push(
|
||||
'Inside repeated children, use { "$item": "field" } for item data and { "$index": true } for the array index.',
|
||||
);
|
||||
lines.push(
|
||||
'For nested lists, an inner repeat can use statePath: { "$item": "field" } to read an array from the enclosing item. This form is valid only inside another repeat.',
|
||||
);
|
||||
lines.push(
|
||||
"ALWAYS use repeat for lists backed by state arrays. NEVER hardcode individual elements per item.",
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/zustand",
|
||||
"version": "0.19.0",
|
||||
"version": "0.20.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Zustand adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -177,7 +177,7 @@ import { validateSpec, autoFixSpec } from "@json-render/core";
|
||||
|
||||
const { valid, issues } = validateSpec(spec);
|
||||
// issues include: missing_child, invalid_visible (malformed conditions),
|
||||
// repeat_without_children, repeat_state_mismatch (statePath not an array in state)
|
||||
// repeat_without_children, repeat_item_outside_scope, repeat_state_mismatch
|
||||
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec);
|
||||
// fixDetails entries are { message, lossy }. Lossless fixes relocate
|
||||
|
||||
@@ -171,6 +171,8 @@ Use the `repeat` field on a container element to render items from a state array
|
||||
|
||||
Inside repeated children, use `{ "$item": "field" }` to read from the current item and `{ "$index": true }` for the current index.
|
||||
|
||||
For nested lists, an inner repeat can use `{ "statePath": { "$item": "children" } }` to iterate an array on the enclosing item.
|
||||
|
||||
## Streaming
|
||||
|
||||
Use `useUIStream` to progressively render specs from JSONL patch streams:
|
||||
|
||||
@@ -95,7 +95,7 @@ const html = await renderToHtml(spec, { registry });
|
||||
|
||||
## Visibility and State
|
||||
|
||||
Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve.
|
||||
Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), nested repeat paths with `{ "$item": "field" }`, and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve.
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ Any prop value can be a data-driven expression resolved at render time:
|
||||
- **`{ "$state": "/state/key" }`** - reads from state model (one-way read)
|
||||
- **`{ "$bindState": "/path" }`** - two-way binding: use on the natural value prop (value, checked, pressed, etc.) of form components.
|
||||
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
|
||||
- Nested lists use `{ "repeat": { "statePath": { "$item": "comments" } } }` inside an enclosing repeat.
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
|
||||
|
||||
```json
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user