Compare commits

..
Author SHA1 Message Date
Chris Tate e6baaf3e71 better visual 2026-03-11 12:10:29 -05:00
116 changed files with 361 additions and 7412 deletions
+1 -2
View File
@@ -19,8 +19,7 @@
"@json-render/xstate",
"@json-render/image",
"@json-render/mcp",
"@json-render/svelte",
"@json-render/solid"
"@json-render/svelte"
]
],
"linked": [],
+43 -118
View File
@@ -21,8 +21,6 @@ npm install @json-render/core @json-render/react-email @react-email/components @
npm install @json-render/core @json-render/vue
# or for Svelte
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
```
## Why json-render?
@@ -32,7 +30,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
- **Cross-Platform** - React, Vue, Svelte (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -93,7 +91,9 @@ const { registry } = defineRegistry(catalog, {
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
<button onClick={() => emit("press")}>
{props.label}
</button>
),
},
});
@@ -113,25 +113,24 @@ function Dashboard({ spec }) {
## Packages
| Package | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
## Renderers
@@ -160,7 +159,7 @@ const spec = {
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />;
<Renderer spec={spec} registry={registry} />
```
### Vue (UI)
@@ -200,26 +199,6 @@ const { registry } = defineRegistry(catalog, {
// <Renderer spec={spec} registry={registry} />
```
### Solid (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
});
<Renderer spec={spec} registry={registry} />;
```
### shadcn/ui (Web)
```tsx
@@ -250,7 +229,7 @@ const { registry } = defineRegistry(catalog, {
},
});
<Renderer spec={spec} registry={registry} />;
<Renderer spec={spec} registry={registry} />
```
### React Native (Mobile)
@@ -271,40 +250,23 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />;
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{
id: "clip-1",
trackId: "main",
component: "TitleCard",
props: { title: "Hello" },
from: 0,
durationInFrames: 90,
},
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
],
audio: { tracks: [] },
audio: { tracks: [] }
};
<Player
@@ -314,7 +276,7 @@ const spec = {
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>;
/>
```
### React PDF (Documents)
@@ -325,11 +287,7 @@ import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: {
type: "Document",
props: { title: "Invoice" },
children: ["page-1"],
},
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
"page-1": {
type: "Page",
props: { size: "A4" },
@@ -343,14 +301,8 @@ const spec = {
"table-1": {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
},
children: [],
},
@@ -375,11 +327,7 @@ const catalog = defineCatalog(schema, {
const spec = {
root: "html-1",
elements: {
"html-1": {
type: "Html",
props: { lang: "en", dir: "ltr" },
children: ["head-1", "body-1"],
},
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
@@ -388,17 +336,11 @@ const spec = {
},
"container-1": {
type: "Container",
props: {
style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
},
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": {
type: "Text",
props: { text: "Thanks for signing up." },
children: [],
},
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
},
};
@@ -483,16 +425,8 @@ Any prop value can be data-driven using expressions:
{
"type": "Icon",
"props": {
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
}
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
}
}
```
@@ -511,10 +445,7 @@ Components can trigger actions, including the built-in `setState` action:
```json
{
"type": "Pressable",
"props": {
"action": "setState",
"actionParams": { "statePath": "/activeTab", "value": "home" }
},
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
"children": ["home-icon"]
}
```
@@ -528,15 +459,9 @@ React to state changes by triggering actions:
```json
{
"type": "Select",
"props": {
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
@@ -561,7 +486,7 @@ pnpm dev
- Chat Example: run `pnpm dev` in `examples/chat`
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
- Vite Renderers (React + Vue + Svelte): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
## How It Works
@@ -571,7 +496,7 @@ flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
-9
View File
@@ -1,14 +1,5 @@
# web
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/codegen@0.12.1
- @json-render/react@0.12.1
## 0.1.5
### Patch Changes
-201
View File
@@ -1,201 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/api/solid");
# @json-render/solid
SolidJS components, providers, and hooks for rendering json-render specs.
## Installation
<PackageInstall packages="@json-render/core @json-render/solid" />
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
<PackageInstall packages="solid-js zod" />
## Providers
### StateProvider
```tsx
<StateProvider
initialState={{}}
onStateChange={(changes) => console.log(changes)}
>
{/* children */}
</StateProvider>
```
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<code>store</code>
</td>
<td>
<code>StateStore</code>
</td>
<td>
External store (controlled mode). When provided,{" "}
<code>initialState</code> and <code>onStateChange</code> are ignored.
</td>
</tr>
<tr>
<td>
<code>initialState</code>
</td>
<td>
<code>Record&lt;string, unknown&gt;</code>
</td>
<td>Initial state model for uncontrolled mode.</td>
</tr>
<tr>
<td>
<code>onStateChange</code>
</td>
<td>
<code>
{"(changes: Array<{ path: string; value: unknown }>) => void"}
</code>
</td>
<td>Called for uncontrolled state updates.</td>
</tr>
</tbody>
</table>
### ActionProvider
```tsx
<ActionProvider
handlers={{ submit: async (params) => {} }}
navigate={(path) => {}}
>
{/* children */}
</ActionProvider>
```
### VisibilityProvider
```tsx
<VisibilityProvider>{/* children */}</VisibilityProvider>
```
### ValidationProvider
```tsx
<ValidationProvider customFunctions={{ custom: (value) => Boolean(value) }}>
{/* children */}
</ValidationProvider>
```
### JSONUIProvider
Combined provider wrapper for state, visibility, validation, and actions.
```tsx
<JSONUIProvider
registry={registry}
initialState={{}}
handlers={handlers}
validationFunctions={validationFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
## defineRegistry
Create a typed component registry and action helpers from a catalog.
```tsx
const { registry, handlers, executeAction } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
actions: {
submit: async (params, setState, state) => {
// custom action logic
},
},
});
```
## Components
### Renderer
```tsx
<Renderer spec={spec} registry={registry} loading={false} />
```
Renders a `Spec` tree using your registry.
### createRenderer
Build an app-level renderer from catalog + components:
```tsx
const AppRenderer = createRenderer(catalog, {
Card: (renderProps) => <div>{renderProps.children}</div>,
});
<AppRenderer spec={spec} state={{}} onAction={(name, params) => {}} />;
```
## Hooks
- `useStateStore()`
- `useStateValue(path)` - returns an accessor
- `useStateBinding(path)` - returns `[Accessor<T | undefined>, setValue]`
- `useVisibility()` / `useIsVisible(condition)`
- `useActions()` / `useAction(binding)`
- `useValidation()` / `useOptionalValidation()`
- `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid`
- `useBoundProp(value, bindingPath)`
- `useUIStream(options)`
- `useChatUI(options)`
## Built-in Actions
`ActionProvider` handles these built-in actions:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
## Component Props
Registry components receive:
```ts
interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: JSX.Element;
emit: (event: string) => void;
on: (event: string) => EventHandle;
bindings?: Record<string, string>;
loading?: boolean;
}
```
Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`).
## Reactivity Notes
- Keep changing reads in JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when you need live updates.
- `StateProvider` and other contexts expose getter-backed values so consumers read live signals.
- `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions.
+14 -85
View File
@@ -1,5 +1,5 @@
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("docs/renderers");
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/renderers")
# Renderers
@@ -22,65 +22,37 @@ All renderers share the same workflow:
<tbody>
<tr>
<td>React</td>
<td>
<code>@json-render/react</code>
</td>
<td><code>@json-render/react</code></td>
<td>React component tree</td>
</tr>
<tr>
<td>Vue</td>
<td>
<code>@json-render/vue</code>
</td>
<td><code>@json-render/vue</code></td>
<td>Vue 3 component tree</td>
</tr>
<tr>
<td>Svelte</td>
<td>
<code>@json-render/svelte</code>
</td>
<td>Svelte 5 component tree</td>
</tr>
<tr>
<td>Solid</td>
<td>
<code>@json-render/solid</code>
</td>
<td>SolidJS component tree</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>
<code>@json-render/shadcn</code>
</td>
<td><code>@json-render/shadcn</code></td>
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
</tr>
<tr>
<td>React Native</td>
<td>
<code>@json-render/react-native</code>
</td>
<td><code>@json-render/react-native</code></td>
<td>Native mobile views</td>
</tr>
<tr>
<td>Image</td>
<td>
<code>@json-render/image</code>
</td>
<td><code>@json-render/image</code></td>
<td>SVG / PNG (via Satori)</td>
</tr>
<tr>
<td>React PDF</td>
<td>
<code>@json-render/react-pdf</code>
</td>
<td><code>@json-render/react-pdf</code></td>
<td>PDF documents</td>
</tr>
<tr>
<td>Remotion</td>
<td>
<code>@json-render/remotion</code>
</td>
<td><code>@json-render/remotion</code></td>
<td>Video compositions</td>
</tr>
</tbody>
@@ -95,7 +67,7 @@ import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />;
<Renderer spec={spec} registry={registry} />
```
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
@@ -118,42 +90,6 @@ const { registry } = defineRegistry(catalog, {
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
## Svelte
Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support.
```typescript
import { defineRegistry, Renderer } from "@json-render/svelte";
import { schema } from "@json-render/svelte/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => /* Svelte snippet */,
},
});
```
See the [@json-render/svelte API reference](/docs/api/svelte) for details.
## Solid
SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions.
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
},
});
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/solid API reference](/docs/api/solid) for details.
## shadcn/ui
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
@@ -189,10 +125,7 @@ Render specs as native mobile views. Includes 25+ standard components and standa
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/react-native/catalog";
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
const catalog = defineCatalog(schema, {
@@ -201,7 +134,7 @@ const catalog = defineCatalog(schema, {
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />;
<Renderer spec={spec} registry={registry} />
```
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
@@ -226,11 +159,7 @@ See the [@json-render/image API reference](/docs/api/image) for details.
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
```typescript
import {
renderToBuffer,
renderToStream,
renderToFile,
} from "@json-render/react-pdf";
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
@@ -256,7 +185,7 @@ import { Renderer } from "@json-render/remotion";
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>;
/>
```
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
+2 -8
View File
@@ -1,6 +1,6 @@
import { pageMetadata } from "@/lib/page-metadata";
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/skills");
export const metadata = pageMetadata("docs/skills")
# Skills
@@ -18,7 +18,6 @@ json-render ships with skills that teach AI coding agents how to use each packag
- **remotion** — Remotion renderer for video generation from JSON timeline specs.
- **vue** — Vue 3 renderer for Vue component trees.
- **svelte** — Svelte 5 renderer for Svelte component trees.
- **solid** — SolidJS renderer for fine-grained reactive component trees.
- **codegen** — Code generation utilities for building custom exporters.
- **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code.
- **redux** — Redux adapter for json-render's `StateStore` interface.
@@ -39,7 +38,6 @@ npx skills add vercel-labs/json-render --skill image
npx skills add vercel-labs/json-render --skill remotion
npx skills add vercel-labs/json-render --skill vue
npx skills add vercel-labs/json-render --skill svelte
npx skills add vercel-labs/json-render --skill solid
npx skills add vercel-labs/json-render --skill codegen
npx skills add vercel-labs/json-render --skill mcp
npx skills add vercel-labs/json-render --skill redux
@@ -90,10 +88,6 @@ Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue
Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration.
## solid
Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming.
## codegen
Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/shadcn, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, image, remotion, vue, svelte, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+218 -61
View File
@@ -1,20 +1,24 @@
"use client";
function Skeleton({ className = "" }: { className?: string }) {
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
}
/** Simple form wireframe reused in both diagrams */
function FormUI() {
function ScheduleItem({
time,
label,
color,
}: {
time: string;
label: string;
color: string;
}) {
return (
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
{/* Title */}
<Skeleton className="h-2.5 w-14 mb-1" />
{/* Input fields */}
<Skeleton className="h-4 w-full rounded-sm" />
<Skeleton className="h-4 w-full rounded-sm" />
{/* Submit button */}
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
<div className="flex items-center gap-2 py-1.5 border-t border-border/50 first:border-t-0">
<span className="text-[10px] text-muted-foreground/60 w-12 shrink-0 tabular-nums">
{time}
</span>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: color }}
/>
<span className="text-[11px] text-muted-foreground">{label}</span>
</div>
);
}
@@ -22,48 +26,164 @@ function FormUI() {
function InlineModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
<div className="text-sm font-medium text-foreground text-center mb-3">
Inline Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
{/* Chat area */}
<div className="flex-1 p-3 overflow-hidden flex justify-center">
<div className="w-1/3 min-w-[120px] space-y-3">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted rounded-xl px-3 py-2">
<Skeleton className="h-2 w-14" />
</div>
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-col">
<div className="flex-1 p-4 space-y-3 overflow-hidden">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-3.5 py-2 max-w-[85%]">
<span className="text-[11px] text-foreground/80">
I have back-to-back meetings today
</span>
</div>
</div>
{/* Assistant text */}
<div className="space-y-2">
<div className="space-y-1.5">
<Skeleton className="h-2 w-full" />
<Skeleton className="h-2 w-3/4" />
</div>
{/* Inline UI */}
<FormUI />
{/* More text after UI */}
<div className="space-y-1.5">
<Skeleton className="h-2 w-4/5" />
{/* AI response */}
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground/70">
Packed day! Here&apos;s what you&apos;ve got:
</p>
<div className="bg-muted/40 border border-border rounded-xl p-3 w-[92%]">
<div className="text-[11px] font-semibold text-foreground/80 mb-2">
Today&apos;s Schedule
</div>
<ScheduleItem
time="10:00 AM"
label="Design Review"
color="#7aa2f7"
/>
<ScheduleItem
time="1:00 PM"
label="Sprint Planning"
color="#bb9af7"
/>
<ScheduleItem
time="3:30 PM"
label="Team Standup"
color="#9ece6a"
/>
<ScheduleItem
time="4:30 PM"
label="Eng All-Hands"
color="#e0af68"
/>
</div>
</div>
</div>
{/* Input bar */}
<div className="p-2 flex justify-center">
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
<Skeleton className="h-7 flex-1 rounded-md" />
<Skeleton className="h-7 w-7 rounded-md" />
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Text + UI interleaved in messages
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
AI decides when UI beats text
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>AI chatbots</span>
<span className="text-muted-foreground/30">/</span>
<span>Copilots</span>
<span className="text-muted-foreground/30">/</span>
<span>Assistants</span>
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<div className="w-full h-full flex flex-col bg-muted/20">
{/* Nav */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
className="shrink-0"
>
<rect
x="2"
y="14"
width="5"
height="8"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="9.5"
y="8"
width="5"
height="14"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="17"
y="2"
width="5"
height="20"
rx="1"
className="fill-muted-foreground/60"
/>
</svg>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground/50">Pricing</span>
<span className="text-[9px] text-muted-foreground/50">Blog</span>
<span className="text-[8px] font-semibold bg-foreground text-background rounded-md px-1.5 py-0.5 whitespace-nowrap">
Get Started
</span>
</div>
</div>
{/* Hero */}
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 text-center px-4">
<span className="text-[8px] font-medium text-green-400 bg-green-400/10 border border-green-400/15 rounded-full px-2 py-0.5">
Trusted by 2,000+ teams
</span>
<div className="text-sm font-bold text-foreground leading-tight">
Analytics that
<br />
move the needle
</div>
<div className="text-[10px] text-muted-foreground/50 leading-relaxed">
Ship what matters. No setup required.
</div>
<div className="flex gap-1.5 mt-1">
<button className="bg-foreground text-background text-[9px] font-semibold rounded-lg px-3 py-1.5 whitespace-nowrap">
Start Free
</button>
<button className="border border-border text-muted-foreground text-[9px] font-medium rounded-lg px-3 py-1.5 whitespace-nowrap">
Book Demo
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-center gap-3 py-2 border-t border-border/50">
<span className="text-[8px] text-muted-foreground/30">Privacy</span>
<span className="text-[8px] text-muted-foreground/30">Terms</span>
<span className="text-[8px] text-muted-foreground/30">Status</span>
</div>
</div>
);
@@ -72,28 +192,65 @@ function InlineModeDiagram() {
function StandaloneModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
<div className="text-sm font-medium text-foreground text-center mb-3">
Standalone Mode
</div>
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
{/* Left panel - prompt */}
<div className="w-[38%] border-r border-border flex flex-col">
<div className="flex-1" />
<div className="p-3 space-y-2">
<Skeleton className="h-7 w-full rounded-md" />
<Skeleton className="h-5 w-16 rounded-md" />
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-row">
{/* Left panel - conversation */}
<div className="w-[38%] border-r border-border/50 flex flex-col">
<div className="flex-1 p-3 space-y-2.5">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-2.5 py-1.5">
<span className="text-[10px] text-foreground/80 block leading-relaxed">
A landing page for my analytics startup
</span>
</div>
</div>
{/* AI text */}
<p className="text-[10px] text-muted-foreground/60 leading-relaxed">
Here&apos;s a clean landing page with a hero, nav, and CTAs.
</p>
</div>
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
{/* Right panel - UI preview */}
<div className="flex-1 p-3 flex items-center justify-center">
<div className="w-3/4">
<FormUI />
</div>
{/* Right panel - landing page */}
<div className="flex-1">
<LandingPage />
</div>
</div>
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
Prompt separate from UI preview
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
Prompt in, UI out
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>Website builders</span>
<span className="text-muted-foreground/30">/</span>
<span>Text-to-widget</span>
<span className="text-muted-foreground/30">/</span>
<span>Dashboards</span>
</div>
</div>
</div>
);
@@ -103,10 +260,10 @@ export function GenerationModesDiagram() {
return (
<div className="not-prose my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="h-[280px]">
<div className="h-[420px]">
<InlineModeDiagram />
</div>
<div className="h-[280px]">
<div className="h-[420px]">
<StandaloneModeDiagram />
</div>
</div>
+1 -2
View File
@@ -92,7 +92,7 @@ export const docsNavigation: NavSection[] = [
external: true,
},
{
title: "Renders with Vite (Vue / React / Svelte / Solid)",
title: "Renders with Vite (Vue / React / Svelte)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
@@ -133,7 +133,6 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
{ title: "@json-render/solid", href: "/docs/api/solid" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
{ title: "@json-render/redux", href: "/docs/api/redux" },
-1
View File
@@ -43,7 +43,6 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-email": "@json-render/react-email API",
"docs/api/react-native": "@json-render/react-native API",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"license": "Apache-2.0",
-9
View File
@@ -1,14 +1,5 @@
# example-chat
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
-9
View File
@@ -1,14 +1,5 @@
# example-dashboard
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/codegen@0.12.1
- @json-render/react@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
-8
View File
@@ -1,13 +1,5 @@
# example-image
## 0.1.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/image@0.12.1
## 0.1.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-image",
"version": "0.1.3",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
-10
View File
@@ -1,15 +1,5 @@
# example-mcp
## 0.1.2
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/mcp@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-mcp",
"version": "0.1.2",
"version": "0.1.1",
"type": "module",
"private": true,
"scripts": {
-9
View File
@@ -1,14 +1,5 @@
# example-no-ai
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/shadcn@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
-8
View File
@@ -1,13 +1,5 @@
# example-react-email
## 0.1.2
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-email@0.12.1
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-email",
"version": "0.1.2",
"version": "0.1.1",
"type": "module",
"private": true,
"scripts": {
-8
View File
@@ -1,13 +1,5 @@
# example-react-native
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-native@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.6",
"version": "0.1.5",
"private": true,
"main": "expo-router/entry",
"scripts": {
-8
View File
@@ -1,13 +1,5 @@
# example-react-pdf
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react-pdf@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
-8
View File
@@ -1,13 +1,5 @@
# example-remotion
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/remotion@0.12.1
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.6",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
@@ -1,13 +1,5 @@
# com.example.json-render-demo
## 0.0.7
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.0.6
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.7",
"version": "0.0.6",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -1,13 +1,5 @@
# com.example.json-render-fullpage-demo
## 0.0.7
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.0.6
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.7",
"version": "0.0.6",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
-8
View File
@@ -1,13 +1,5 @@
# svelte-chat
## 0.0.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/svelte@0.12.1
## 0.0.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "svelte-chat",
"private": true,
"version": "0.0.3",
"version": "0.0.2",
"type": "module",
"scripts": {
"dev": "vite dev",
-8
View File
@@ -1,13 +1,5 @@
# example-svelte
## 0.1.3
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/svelte@0.12.1
## 0.1.2
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-svelte",
"version": "0.1.3",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
-10
View File
@@ -1,15 +1,5 @@
# vite-renderers
## 0.1.4
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
- @json-render/svelte@0.12.1
- @json-render/vue@0.12.1
## 0.1.3
### Patch Changes
+2 -5
View File
@@ -1,6 +1,6 @@
{
"name": "vite-renderers",
"version": "0.1.4",
"version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -12,12 +12,10 @@
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/solid": "workspace:*",
"@json-render/svelte": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.49.2",
"vue": "^3.5.29",
"zod": "4.3.6"
@@ -29,7 +27,6 @@
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-solid": "^2.11.10"
"vite": "^7.3.1"
}
}
+2 -6
View File
@@ -1,7 +1,7 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react" | "svelte" | "solid";
type Renderer = "vue" | "react" | "svelte";
const container = document.getElementById("renderer-root") as HTMLElement;
@@ -18,12 +18,8 @@ async function switchTo(renderer: Renderer) {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else if (renderer === "svelte") {
const mod = await import("./svelte/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./solid/mount.tsx");
const mod = await import("./svelte/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
+1 -14
View File
@@ -123,9 +123,7 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
: "Rendered with Svelte"}
</span>
),
@@ -166,17 +164,6 @@ export const components: Components<AppCatalog> = {
>
Svelte
</button>
<button
onClick={() => emit("pressSolid")}
className={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
@@ -65,7 +65,7 @@ export const catalogDef = {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
"Segmented tab control for switching between Vue, React, and Svelte renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
@@ -99,9 +99,5 @@ export const catalogDef = {
params: z.object({}),
description: "Switch to the Svelte renderer",
},
switchToSolid: {
params: z.object({}),
description: "Switch to the Solid renderer",
},
},
};
@@ -10,7 +10,6 @@ export const actionStubs = {
switchToVue: async () => {},
switchToReact: async () => {},
switchToSvelte: async () => {},
switchToSolid: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
@@ -53,10 +52,5 @@ export function makeHandlers(get: Get, set: Set) {
new CustomEvent("switch-renderer", { detail: "svelte" }),
);
},
switchToSolid: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "solid" }),
);
},
};
}
+9 -40
View File
@@ -11,15 +11,9 @@
align-items: center;
}
.json-render-stack--align-start {
align-items: flex-start;
}
.json-render-stack--align-center {
align-items: center;
}
.json-render-stack--align-end {
align-items: flex-end;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
/* ---- Card ----------------------------------------------------------------- */
@@ -56,22 +50,12 @@
color: #111827;
}
.json-render-text--sm {
font-size: 12px;
}
.json-render-text--lg {
font-size: 16px;
}
.json-render-text--xl {
font-size: 24px;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--medium {
font-weight: 500;
}
.json-render-text--bold {
font-weight: 700;
}
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
/* ---- Button --------------------------------------------------------------- */
@@ -227,7 +211,7 @@
transition: background 0.15s;
}
.json-render-renderer-tab:not(:last-child) {
.json-render-renderer-tab:first-child {
border-right: 1px solid #e5e7eb;
}
@@ -277,18 +261,3 @@
background-color: #ff3e00;
color: white;
}
.renderer-solid .json-render-renderer-badge {
color: #2c4f7c;
background-color: #2c4f7c18;
border-color: #2c4f7c40;
}
.renderer-solid .json-render-renderer-dot {
background-color: #2c4f7c;
}
.renderer-solid .json-render-renderer-tab--active {
background-color: #2c4f7c;
color: white;
}
-19
View File
@@ -1,19 +0,0 @@
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/solid";
import { DemoRenderer } from "./DemoRenderer";
export function App(props: { initialRenderer?: string; spec: Spec }) {
const renderer = props.initialRenderer ?? "solid";
const initialState = {
...props.spec.state,
renderer,
};
return (
<div class={`renderer-${renderer}`}>
<StateProvider initialState={initialState}>
<DemoRenderer spec={props.spec} />
</StateProvider>
</div>
);
}
@@ -1,32 +0,0 @@
import type { Spec } from "@json-render/core";
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/solid";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
export function DemoRenderer(props: { spec: Spec }) {
const stateStore = useStateStore();
const handlers = makeHandlers(stateStore.get, stateStore.set);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={props.spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
@@ -1,5 +0,0 @@
import { schema } from "@json-render/solid/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -1,17 +0,0 @@
import { render } from "solid-js/web";
import type { Spec } from "@json-render/core";
import { App } from "./App";
let dispose: (() => void) | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
dispose = render(
() => <App initialRenderer={renderer} spec={spec} />,
container,
);
}
export function unmount() {
dispose?.();
dispose = null;
}
@@ -1,194 +0,0 @@
import type { Components } from "@json-render/solid";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: (renderProps) => (
<div
class={[
"json-render-stack",
renderProps.props.direction === "horizontal" &&
"json-render-stack--horizontal",
renderProps.props.align &&
`json-render-stack--align-${renderProps.props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: renderProps.props.gap ? `${renderProps.props.gap}px` : undefined,
padding: renderProps.props.padding
? `${renderProps.props.padding}px`
: undefined,
}}
>
{renderProps.children}
</div>
),
Card: (renderProps) => (
<div class="json-render-card">
{renderProps.props.title && (
<div class="json-render-card-title-wrap">
<h2 class="json-render-card-title">{renderProps.props.title}</h2>
</div>
)}
{renderProps.props.subtitle && (
<p class="json-render-card-subtitle">{renderProps.props.subtitle}</p>
)}
{renderProps.children}
</div>
),
Text: (renderProps) => (
<span
class={[
"json-render-text",
renderProps.props.size !== "md" &&
renderProps.props.size &&
`json-render-text--${renderProps.props.size}`,
renderProps.props.weight !== "normal" &&
renderProps.props.weight &&
`json-render-text--${renderProps.props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={renderProps.props.color ? { color: renderProps.props.color } : {}}
>
{String(renderProps.props.content ?? "")}
</span>
),
Button: (renderProps) => (
<button
disabled={renderProps.props.disabled}
onClick={() => renderProps.emit("press")}
class={[
"json-render-button",
renderProps.props.variant &&
`json-render-button--${renderProps.props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.label}
</button>
),
Badge: (renderProps) => (
<span
class="json-render-badge"
style={
renderProps.props.color
? {
"background-color": `${renderProps.props.color}20`,
color: renderProps.props.color,
"border-color": `${renderProps.props.color}40`,
}
: {}
}
>
{renderProps.props.label}
</span>
),
ListItem: (renderProps) => (
<div
onClick={() => renderProps.emit("press")}
class={[
"json-render-list-item",
renderProps.props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
class={[
"json-render-list-item-check",
renderProps.props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.completed ? "✓" : ""}
</div>
<span
class={[
"json-render-list-item-text",
renderProps.props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{renderProps.props.title}
</span>
</div>
),
RendererBadge: (renderProps) => (
<span class="json-render-renderer-badge">
<span class="json-render-renderer-dot" />
{renderProps.props.renderer === "vue"
? "Rendered with Vue"
: renderProps.props.renderer === "react"
? "Rendered with React"
: renderProps.props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
</span>
),
RendererTabs: (renderProps) => (
<div class="json-render-renderer-tabs-wrapper">
<span class="json-render-renderer-tabs-label">Render</span>
<div class="json-render-renderer-tabs">
<button
onClick={() => renderProps.emit("pressVue")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "vue" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => renderProps.emit("pressReact")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "react" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
<button
onClick={() => renderProps.emit("pressSvelte")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "svelte" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Svelte
</button>
<button
onClick={() => renderProps.emit("pressSolid")}
class={[
"json-render-renderer-tab",
renderProps.props.renderer === "solid" &&
"json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Solid
</button>
</div>
</div>
),
};
+1 -2
View File
@@ -10,7 +10,7 @@ export const demoSpec: Spec = {
{
id: 2,
title:
"Try @json-render/vue, @json-render/react, @json-render/svelte, and @json-render/solid",
"Try @json-render/vue, @json-render/react, and @json-render/svelte",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
@@ -49,7 +49,6 @@ export const demoSpec: Spec = {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
pressSvelte: { action: "switchToSvelte" },
pressSolid: { action: "switchToSolid" },
},
},
@@ -12,7 +12,5 @@
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid"}
: "Rendered with Svelte"}
</span>
@@ -42,16 +42,5 @@
.join(" ")}>
Svelte
</button>
<button
type="button"
onclick={() => emit("pressSolid")}
class={[
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}>
Solid
</button>
</div>
</div>
+1 -16
View File
@@ -132,9 +132,7 @@ export const components: Components<AppCatalog> = {
? "Rendered with Vue"
: props.renderer === "react"
? "Rendered with React"
: props.renderer === "svelte"
? "Rendered with Svelte"
: "Rendered with Solid",
: "Rendered with Svelte",
]),
RendererTabs: ({ props, emit }) =>
@@ -180,19 +178,6 @@ export const components: Components<AppCatalog> = {
},
"Svelte",
),
h(
"button",
{
onClick: () => emit("pressSolid"),
class: [
"json-render-renderer-tab",
props.renderer === "solid" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Solid",
),
]),
]),
};
+1 -7
View File
@@ -2,13 +2,7 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import solid from "vite-plugin-solid";
export default defineConfig({
plugins: [
svelte(),
vue(),
react({ include: /src\/react\/.*\.tsx$/ }),
solid({ include: [/src\/solid\/.*\.tsx$/, /packages\/solid\/.*\.tsx$/] }),
],
plugins: [svelte(), vue(), react({ include: /\.tsx$/ })],
});
-8
View File
@@ -1,13 +1,5 @@
# example-vue
## 0.1.4
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/vue@0.12.1
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-vue",
"version": "0.1.4",
"version": "0.1.3",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+1 -11
View File
@@ -32,7 +32,6 @@
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@solidjs/testing-library": "^0.8.10",
"@testing-library/svelte": "^5.2.0",
"@types/react": "^19.2.3",
"husky": "^9.1.7",
@@ -41,11 +40,9 @@
"prettier": "^3.7.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"solid-js": "^1.9.11",
"svelte": "^5.0.0",
"turbo": "^2.7.4",
"typescript": "5.9.2",
"vite-plugin-solid": "^2.11.10",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@10.29.3",
@@ -54,12 +51,5 @@
},
"lint-staged": {
"*.{ts,tsx}": "prettier --write"
},
"workspaces": [
"apps/*",
"examples/*",
"examples/stripe-app/*",
"packages/*",
"tests/*"
]
}
}
-7
View File
@@ -1,12 +1,5 @@
# @json-render/codegen
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
-17
View File
@@ -1,22 +1,5 @@
# @json-render/core
## 0.12.1
### Patch Changes
- 54a1ecf: Rename generation modes and fix MCP React duplicate module error.
### Changed:
- **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning.
### Fixed:
- **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point.
### Other:
- Updated `homepage` URLs across all packages to point to `https://json-render.dev`.
- Reorganized skills directory structure for cleaner naming.
- Added skills documentation page to the web app.
## 0.12.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/image
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/jotai
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
-20
View File
@@ -1,25 +1,5 @@
# @json-render/mcp
## 0.12.1
### Patch Changes
- 54a1ecf: Rename generation modes and fix MCP React duplicate module error.
### Changed:
- **`@json-render/core`** — Renamed generation modes from `"generate"` / `"chat"` to `"standalone"` / `"inline"`. The old names still work but emit a deprecation warning.
### Fixed:
- **`@json-render/mcp`** — Resolved React duplicate module error (`useRef` returning null) by adding `resolve.dedupe` Vite configuration. Added `./build-app-html` export entry point.
### Other:
- Updated `homepage` URLs across all packages to point to `https://json-render.dev`.
- Reorganized skills directory structure for cleaner naming.
- Added skills documentation page to the web app.
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/mcp",
"version": "0.12.1",
"version": "0.12.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": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/react-email
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-email",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/react-native
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/react-pdf
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-pdf",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @internal/react-state
## 0.8.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.8.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@internal/react-state",
"version": "0.8.6",
"version": "0.8.5",
"private": true,
"license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages",
-7
View File
@@ -1,12 +1,5 @@
# @json-render/react
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/redux
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/redux",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "Redux adapter for json-render StateStore",
"keywords": [
-7
View File
@@ -1,12 +1,5 @@
# @json-render/remotion
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
-8
View File
@@ -1,13 +1,5 @@
# @json-render/shadcn
## 0.12.1
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/react@0.12.1
## 0.12.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn",
"version": "0.12.1",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
-243
View File
@@ -1,243 +0,0 @@
# @json-render/solid
SolidJS renderer for json-render. Turn JSON specs into Solid components with data binding, visibility, actions, validation, and streaming.
## Installation
```bash
npm install @json-render/core @json-render/solid zod
```
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/solid/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
description: "A card container",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
Input: {
props: z.object({
value: z.union([z.string(), z.record(z.unknown())]).nullable(),
label: z.string(),
placeholder: z.string().nullable(),
}),
description: "Text input with optional state binding",
},
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
```
### 2. Define Component Implementations
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions.
```tsx
import { defineRegistry, useBoundProp } from "@json-render/solid";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => (
<div class="card">
<h3>{renderProps.element.props.title as string}</h3>
{renderProps.children}
</div>
),
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
Input: (renderProps) => {
const [value, setValue] = useBoundProp(
renderProps.element.props.value,
renderProps.bindings?.value,
);
return (
<label>
{renderProps.element.props.label as string}
<input
value={String(value() ?? "")}
placeholder={String(renderProps.element.props.placeholder ?? "")}
onInput={(e) => setValue(e.currentTarget.value)}
/>
</label>
);
},
},
actions: {
submit: async () => {},
cancel: async () => {},
},
});
```
### 3. Render Specs
```tsx
import { Renderer, StateProvider, ActionProvider } from "@json-render/solid";
import { registry } from "./registry";
export function App(props: { spec: any }) {
return (
<StateProvider initialState={{ form: { name: "" } }}>
<ActionProvider handlers={{ submit: () => console.log("submit") }}>
<Renderer spec={props.spec} registry={registry} />
</ActionProvider>
</StateProvider>
);
}
```
## Spec Format
`@json-render/solid` uses the same flat element map format as React/Vue:
```typescript
interface Spec {
root: string;
elements: Record<string, UIElement>;
state?: Record<string, unknown>;
}
interface UIElement {
type: string;
props: Record<string, unknown>;
children?: string[];
visible?: VisibilityCondition;
watch?: Record<string, ActionBinding | ActionBinding[]>;
}
```
## Providers
| Provider | Purpose |
| --------------------- | ---------------------------------------------------- |
| `StateProvider` | State model and JSON Pointer read/write APIs |
| `ActionProvider` | Action dispatch, loading tracking, confirmation flow |
| `VisibilityProvider` | Visibility condition evaluation from current state |
| `ValidationProvider` | Field-level and full-form validation |
| `RepeatScopeProvider` | Repeat context (`$item`, `$index`, `$bindItem`) |
| `JSONUIProvider` | Combined provider wiring for renderer trees |
### External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or the component returned by `createRenderer`.
When `store` is provided, `initialState` and `onStateChange` are ignored.
```tsx
import { createStateStore, StateProvider } from "@json-render/solid";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>{/* ... */}</StateProvider>;
```
## Hooks
| Hook | Purpose |
| --------------------------------------------- | ----------------------------------------------------- |
| `useStateStore()` | Access `state`, `get`, `set`, `update`, `getSnapshot` |
| `useStateValue(path)` | Read a value by JSON Pointer path via accessor |
| `useStateBinding(path)` | Legacy two-way binding helper returning an accessor |
| `useVisibility()` / `useIsVisible()` | Visibility context and checks |
| `useActions()` / `useAction()` | Action context and single-action helper |
| `useValidation()` / `useOptionalValidation()` | Validation context (throwing/non-throwing) |
| `useFieldValidation(path, config)` | Field state accessors plus validate/touch/clear |
| `useBoundProp(value, binding)` | Fine-grained two-way binding helper |
| `useUIStream(options)` | Stream UI specs from an endpoint |
| `useChatUI(options)` | Chat-style spec generation hook |
## Built-in Actions
These actions are available in the Solid schema and handled by `ActionProvider`:
- `setState`
- `pushState`
- `removeState`
- `validateForm`
`setState`/`pushState`/`removeState` mutate the state model. `validateForm` validates registered fields and writes `{ valid, errors }` to state (`/formValidation` by default).
## Events and Action Binding
Components can use either `emit("event")` or `on("event")`.
- `emit` fires named event bindings directly.
- `on` returns an `EventHandle` with `emit`, `bound`, and `shouldPreventDefault`.
This mirrors the React package API while preserving Solid's fine-grained reactivity.
## Streaming
`useUIStream` and `useChatUI` support JSON patch streaming and mixed text/spec data parts.
```tsx
import { useUIStream } from "@json-render/solid";
const stream = useUIStream({ api: "/api/generate" });
await stream.send("Build me a dashboard");
```
## Key Exports
| Export | Purpose |
| ------------------ | -------------------------------------------------------------------------- |
| `defineRegistry` | Create catalog-aware component and action registry helpers |
| `Renderer` | Render a `Spec` with a component registry |
| `createRenderer` | Build an app-level renderer with provider wiring |
| `JSONUIProvider` | Combined provider tree (`state` + `visibility` + `validation` + `actions`) |
| `schema` | Solid element schema with built-in actions |
| `createStateStore` | Framework-agnostic in-memory `StateStore` |
### Types
| Export | Purpose |
| --------------------------- | -------------------------------------------------------- |
| `ComponentContext` | Catalog-aware component context type |
| `BaseComponentProps` | Catalog-agnostic component props type |
| `EventHandle` | Event metadata (`emit`, `bound`, `shouldPreventDefault`) |
| `StateStore` | Controlled state backend interface |
| `StateModel` | Renderer state model type |
| `SolidSchema` / `SolidSpec` | Solid schema/spec types |
## Differences from `@json-render/react`
Most APIs are intentionally aligned, but there are runtime behavior differences due to Solid:
- Solid components run once, then update via signals.
- Keep changing reads inside JSX expressions, `createMemo`, or `createEffect`.
- Avoid props destructuring in component signatures when values should remain reactive.
- Hooks that read changing state return accessors; call them inside JSX or effects.
## Documentation
Full docs: [json-render.dev/docs/api/solid](https://json-render.dev/docs/api/solid)
## License
Apache-2.0
-69
View File
@@ -1,69 +0,0 @@
{
"name": "@json-render/solid",
"version": "0.12.0",
"license": "Apache-2.0",
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
"keywords": [
"json",
"ui",
"solid",
"solidjs",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/solid"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"esbuild-plugin-solid": "^0.6.0",
"solid-js": "^1.9.0",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"solid-js": "^1.9.0",
"zod": "^4.0.0"
}
}
-154
View File
@@ -1,154 +0,0 @@
import type { JSX } from "solid-js";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
/**
* State setter function for updating application state
*/
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
/**
* Handle returned by the `on()` function for a specific event.
* Provides metadata about the event binding and a method to fire it.
*
* @example
* ```ts
* const press = on("press");
* if (press.shouldPreventDefault) e.preventDefault();
* press.emit();
* ```
*/
export interface EventHandle {
/** Fire the event (resolve action bindings) */
emit: () => void;
/** Whether any binding requested preventDefault */
shouldPreventDefault: boolean;
/** Whether any handler is bound to this event */
bound: boolean;
}
/**
* Catalog-agnostic base type for component render function arguments.
* Use this when building reusable component libraries that are not tied
* to a specific catalog.
*
* @example
* ```ts
* const Card = (ctx: BaseComponentProps<{ title?: string }>) => (
* <div>{ctx.props.title}{ctx.children}</div>
* );
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
children?: JSX.Element;
/** 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. */
on: (event: string) => EventHandle;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Context passed to component render functions
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => {
* return <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for Solid
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = (ctx) => (
* <button onClick={() => ctx.emit("press")}>{ctx.props.label}</button>
* );
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => JSX.Element;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Button: (ctx) => <button>{ctx.props.label}</button>,
* Input: (ctx) => <input placeholder={ctx.props.placeholder} />,
* };
*/
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
// =============================================================================
// Action Types
// =============================================================================
/**
* Action handler function type
* @example
* const viewCustomers: ActionFn<typeof catalog, 'viewCustomers'> = async (params, setState) => {
* const data = await fetch('/api/customers');
* setState(prev => ({ ...prev, customers: data }));
* };
*/
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Registry of all action handlers for a catalog
* @example
* const actions: Actions<typeof myCatalog> = {
* viewCustomers: async (params, setState) => { ... },
* createCustomer: async (params, setState) => { ... },
* };
*/
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
/**
* True when the catalog declares at least one action, false otherwise.
* Used by defineRegistry to conditionally require the `actions` field.
*/
export type CatalogHasActions<C extends Catalog> = [
InferCatalogActions<C>,
] extends [never]
? false
: [keyof InferCatalogActions<C>] extends [never]
? false
: true;
-193
View File
@@ -1,193 +0,0 @@
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
// DO NOT destructure props — Solid requires proxy access
function Button(props: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => props.emit("press")}>
{props.element.props.label}
</button>
);
}
function Text(props: ComponentRenderProps<{ text: unknown }>) {
const display = () => {
const v = props.element.props.text;
return typeof v === "string" ? v : JSON.stringify(v);
};
return <span data-testid={`text-${props.element.type}`}>{display()}</span>;
}
let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
function StateProbe() {
const ctx = useStateStore();
probeGetSnapshot = ctx.getSnapshot;
return <div data-testid="state-probe" />;
}
const registry = {
Button,
Text,
};
function getProbeState(): Record<string, unknown> {
return probeGetSnapshot!();
}
describe("chained actions: live $state resolution (#141)", () => {
it("setState after pushState sees the post-push value via $state", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: ["initial"], observed: "not yet set" },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Add Item" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "new-item" },
},
{
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["initial", "new-item"]);
expect(state.observed).toEqual(["initial", "new-item"]);
});
});
it("multiple pushState + setState chain resolves correctly", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { items: [], snapshot: null },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "pushState",
params: { statePath: "/items", value: "a" },
},
{
action: "pushState",
params: { statePath: "/items", value: "b" },
},
{
action: "setState",
params: {
statePath: "/snapshot",
value: { $state: "/items" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.items).toEqual(["a", "b"]);
expect(state.snapshot).toEqual(["a", "b"]);
});
});
it("setState reading a path mutated by an earlier setState sees fresh value", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { counter: 0, counterCopy: -1 },
root: "main",
elements: {
main: {
type: "Button",
props: { label: "Go" },
on: {
press: [
{
action: "setState",
params: { statePath: "/counter", value: 42 },
},
{
action: "setState",
params: {
statePath: "/counterCopy",
value: { $state: "/counter" },
},
},
],
},
},
},
};
function App() {
return (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
<StateProbe />
</JSONUIProvider>
);
}
render(() => <App />);
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getProbeState();
expect(state.counter).toBe(42);
expect(state.counterCopy).toBe(42);
});
});
});
@@ -1,346 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@solidjs/testing-library";
import type { JSX } from "solid-js";
import { StateProvider, useStateStore } from "./state";
import {
ValidationProvider,
useValidation,
useFieldValidation,
} from "./validation";
import { ActionProvider, useActions, useAction } from "./actions";
/**
* Build the full provider tree inline so that Solid's eager JSX evaluation
* always runs ActionProvider/ValidationProvider INSIDE StateProvider's scope.
*
* IMPORTANT: Do NOT pre-build JSX fragments as variables — Solid evaluates
* JSX eagerly, so `<ActionProvider>` would call `useStateStore()` before the
* StateProvider context is available.
*/
function withProviders<T>(
hook: () => T,
options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
} = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return result;
}
function withFullProviders(options: {
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
withValidation?: boolean;
}): {
stateCtx: ReturnType<typeof useStateStore>;
actionsCtx: ReturnType<typeof useActions>;
validationCtx?: ReturnType<typeof useValidation>;
} {
let stateCtx!: ReturnType<typeof useStateStore>;
let actionsCtx!: ReturnType<typeof useActions>;
let validationCtx: ReturnType<typeof useValidation> | undefined;
function TestComponent(): JSX.Element {
stateCtx = useStateStore();
actionsCtx = useActions();
if (options.withValidation) {
validationCtx = useValidation();
}
return (<div />) as JSX.Element;
}
if (options.withValidation) {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ValidationProvider>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</ValidationProvider>
</StateProvider>
));
} else {
render(() => (
<StateProvider initialState={options.initialState ?? {}}>
<ActionProvider handlers={options.handlers}>
<TestComponent />
</ActionProvider>
</StateProvider>
));
}
return { stateCtx, actionsCtx, validationCtx };
}
describe("ActionProvider — provide/inject", () => {
it("useActions() throws outside a provider", () => {
expect(() => useActions()).toThrow(
"useActions must be used within an ActionProvider",
);
});
});
describe("ActionProvider — built-in setState", () => {
it("executes setState and updates state", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { count: 0 },
});
await actionsCtx.execute({
action: "setState",
params: { statePath: "/count", value: 5 },
});
expect(stateCtx.get("/count")).toBe(5);
});
});
describe("ActionProvider — built-in pushState", () => {
it("appends to existing array", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b"] },
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/items", value: "c" },
});
expect(stateCtx.get("/items")).toEqual(["a", "b", "c"]);
});
it("creates array if path does not exist", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: {},
});
await actionsCtx.execute({
action: "pushState",
params: { statePath: "/newList", value: "first" },
});
expect(stateCtx.get("/newList")).toEqual(["first"]);
});
});
describe("ActionProvider — built-in removeState", () => {
it("removes item by index", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { items: ["a", "b", "c"] },
});
await actionsCtx.execute({
action: "removeState",
params: { statePath: "/items", index: 1 },
});
expect(stateCtx.get("/items")).toEqual(["a", "c"]);
});
});
describe("ActionProvider — built-in push/pop navigation", () => {
it("push sets /currentScreen and /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "home" },
});
await actionsCtx.execute({
action: "push",
params: { screen: "settings" },
});
expect(stateCtx.get("/currentScreen")).toBe("settings");
expect(stateCtx.get("/navStack")).toEqual(["home"]);
});
it("pop restores previous screen from /navStack", async () => {
const { stateCtx, actionsCtx } = withFullProviders({
initialState: { currentScreen: "settings", navStack: ["home"] },
});
await actionsCtx.execute({ action: "pop" });
expect(stateCtx.get("/currentScreen")).toBe("home");
expect(stateCtx.get("/navStack")).toEqual([]);
});
});
describe("ActionProvider — custom handlers", () => {
it("executes custom handler with params", async () => {
const customHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: customHandler },
});
await actionsCtx.execute({
action: "myAction",
params: { foo: "bar" },
});
expect(customHandler).toHaveBeenCalledWith({ foo: "bar" });
});
it("console.warn for unknown action", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({});
await actionsCtx.execute({ action: "unknownAction" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("unknownAction"),
);
warnSpy.mockRestore();
});
it("tracks loading state during async action execution", async () => {
let resolveHandler!: () => void;
const slowHandler = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveHandler = resolve;
}),
);
let executeFn!: ReturnType<typeof useActions>["execute"];
const { findByTestId } = render(() => {
function Inner(): JSX.Element {
const actions = useActions();
executeFn = actions.execute;
return (
<span data-testid="loading">
{actions.loadingActions.has("slowAction") ? "true" : "false"}
</span>
);
}
return (
<StateProvider initialState={{}}>
<ActionProvider handlers={{ slowAction: slowHandler }}>
<Inner />
</ActionProvider>
</StateProvider>
);
});
// Before execution — not loading
const el = await findByTestId("loading");
expect(el.textContent).toBe("false");
const executePromise = executeFn({ action: "slowAction" });
// During execution — loading (need to wait for Solid's reactivity to flush)
await vi.waitFor(() => {
expect(el.textContent).toBe("true");
});
resolveHandler();
await executePromise;
// After execution — no longer loading
await vi.waitFor(() => {
expect(el.textContent).toBe("false");
});
});
it("registerHandler allows dynamic handler registration", async () => {
const dynamicHandler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({});
actionsCtx.registerHandler("dynamicAction", dynamicHandler);
await actionsCtx.execute({
action: "dynamicAction",
params: { x: 1 },
});
expect(dynamicHandler).toHaveBeenCalledWith({ x: 1 });
});
it("handler receives resolved params object", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { actionsCtx } = withFullProviders({
handlers: { myAction: handler },
});
await actionsCtx.execute({
action: "myAction",
params: { x: 1, y: "hello" },
});
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
});
});
describe("ActionProvider — validateForm", () => {
it("writes { valid, errors } to state", async () => {
const { stateCtx, actionsCtx, validationCtx } = withFullProviders({
initialState: {},
withValidation: true,
});
validationCtx!.registerField("/form/email", {
checks: [{ type: "required", message: "Required" }],
});
await actionsCtx.execute({ action: "validateForm" });
expect(stateCtx.get("/formValidation")).toEqual({
valid: false,
errors: { "/form/email": ["Required"] },
});
});
it("warns without ValidationProvider", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { actionsCtx } = withFullProviders({
withValidation: false,
});
await actionsCtx.execute({ action: "validateForm" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("validateForm action was dispatched"),
);
warnSpy.mockRestore();
});
});
describe("useAction", () => {
it("returns { execute, isLoading: false } before execution", () => {
const result = withProviders(() => useAction({ action: "myAction" }), {
handlers: { myAction: vi.fn() },
});
expect(typeof result.execute).toBe("function");
expect(result.isLoading).toBe(false);
});
});
-400
View File
@@ -1,400 +0,0 @@
import {
createContext,
useContext,
createSignal,
type ParentProps,
type JSX,
} from "solid-js";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
let idCounter = 0;
function generateUniqueId(): string {
idCounter += 1;
return `${Date.now()}-${idCounter}`;
}
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
): unknown {
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get));
}
if (typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = deepResolveValue(val, get);
}
return resolved;
}
return value;
}
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
resolve: () => void;
reject: () => void;
}
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
pendingConfirmation: PendingConfirmation | null;
execute: (binding: ActionBinding) => Promise<void>;
confirm: () => void;
cancel: () => void;
registerHandler: (name: string, handler: ActionHandler) => void;
}
const ActionContext = createContext<ActionContextValue | null>(null);
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
}
export function ActionProvider(props: ParentProps<ActionProviderProps>) {
const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const [handlers, setHandlers] = createSignal<Record<string, ActionHandler>>(
props.handlers ?? {},
);
const [loadingActions, setLoadingActions] = createSignal<Set<string>>(
new Set(),
);
const [pendingConfirmation, setPendingConfirmation] =
createSignal<PendingConfirmation | null>(null);
const registerHandler = (name: string, handler: ActionHandler) => {
setHandlers((prev) => ({ ...prev, [name]: handler }));
};
const execute = async (binding: ActionBinding) => {
const resolved = resolveAction(binding, getSnapshot());
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
}
return;
}
if (resolved.action === "pushState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, get);
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
}
}
return;
}
if (resolved.action === "removeState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(
statePath,
arr.filter((_, i) => i !== index),
);
}
return;
}
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
if (screen) {
const currentScreen = get("/currentScreen") as string | undefined;
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
set("/navStack", [...navStack, currentScreen]);
} else {
set("/navStack", [...navStack, ""]);
}
set("/currentScreen", screen);
}
return;
}
if (resolved.action === "pop") {
const navStack = (get("/navStack") as string[] | undefined) ?? [];
if (navStack.length > 0) {
const previousScreen = navStack[navStack.length - 1];
set("/navStack", navStack.slice(0, -1));
if (previousScreen) {
set("/currentScreen", previousScreen);
} else {
set("/currentScreen", undefined);
}
}
return;
}
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers()[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
if (resolved.confirm) {
return new Promise<void>((resolve, reject) => {
setPendingConfirmation({
action: resolved,
handler,
resolve: () => {
setPendingConfirmation(null);
resolve();
},
reject: () => {
setPendingConfirmation(null);
reject(new Error("Action cancelled"));
},
});
}).then(async () => {
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name: string) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
});
}
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name: string) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
};
const confirm = () => {
pendingConfirmation()?.resolve();
};
const cancel = () => {
pendingConfirmation()?.reject();
};
const ctx: ActionContextValue = {
get handlers() {
return handlers();
},
get loadingActions() {
return loadingActions();
},
get pendingConfirmation() {
return pendingConfirmation();
},
execute,
confirm,
cancel,
registerHandler,
};
return (
<ActionContext.Provider value={ctx}>
{props.children}
</ActionContext.Provider>
);
}
export function useActions(): ActionContextValue {
const ctx = useContext(ActionContext);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: boolean;
} {
const actions = useActions();
return {
execute: () => actions.execute(binding),
get isLoading() {
return actions.loadingActions.has(binding.action);
},
};
}
export interface ConfirmDialogProps {
confirm: ActionConfirm;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmDialog(props: ConfirmDialogProps): JSX.Element {
const isDanger = () => props.confirm.variant === "danger";
return (
<div
style={{
position: "fixed",
inset: "0",
"background-color": "rgba(0, 0, 0, 0.5)",
display: "flex",
"align-items": "center",
"justify-content": "center",
"z-index": 50,
}}
onClick={props.onCancel}
>
<div
style={{
"background-color": "white",
"border-radius": "8px",
padding: "24px",
"max-width": "400px",
width: "100%",
"box-shadow": "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 8px 0",
"font-size": "18px",
"font-weight": "600",
}}
>
{props.confirm.title}
</h3>
<p
style={{
margin: "0 0 24px 0",
color: "#6b7280",
}}
>
{props.confirm.message}
</p>
<div
style={{
display: "flex",
gap: "12px",
"justify-content": "flex-end",
}}
>
<button
onClick={props.onCancel}
style={{
padding: "8px 16px",
"border-radius": "6px",
border: "1px solid #d1d5db",
"background-color": "white",
cursor: "pointer",
}}
>
{props.confirm.cancelLabel ?? "Cancel"}
</button>
<button
onClick={props.onConfirm}
style={{
padding: "8px 16px",
"border-radius": "6px",
border: "none",
"background-color": isDanger() ? "#dc2626" : "#3b82f6",
color: "white",
cursor: "pointer",
}}
>
{props.confirm.confirmLabel ?? "Confirm"}
</button>
</div>
</div>
</div>
);
}
@@ -1,23 +0,0 @@
import { createContext, useContext, type ParentProps } from "solid-js";
export interface RepeatScopeValue {
item: unknown;
index: number;
basePath: string;
}
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
export function RepeatScopeProvider(props: ParentProps<RepeatScopeValue>) {
return (
<RepeatScopeContext.Provider
value={{ item: props.item, index: props.index, basePath: props.basePath }}
>
{props.children}
</RepeatScopeContext.Provider>
);
}
export function useRepeatScope(): RepeatScopeValue | null {
return useContext(RepeatScopeContext);
}
-215
View File
@@ -1,215 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render } from "@solidjs/testing-library";
import { createEffect, createSignal, type Accessor, type JSX } from "solid-js";
import { createStateStore } from "@json-render/core";
import {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
} from "./state";
function renderWithState<T>(
hook: () => T,
initialState: Record<string, unknown> = {},
extraProps?: { store?: any; onStateChange?: any },
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div data-testid="test" />) as unknown as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState} {...(extraProps ?? {})}>
<TestComponent />
</StateProvider>
));
return result;
}
describe("StateProvider + hooks", () => {
it("useStateStore() throws outside provider", () => {
expect(() => useStateStore()).toThrow(
"useStateStore must be used within a StateProvider",
);
});
it("StateProvider + useStateStore round-trip (get + set)", () => {
const ctx = renderWithState(() => useStateStore(), { count: 0 });
expect(ctx.get("/count")).toBe(0);
ctx.set("/count", 42);
expect(ctx.get("/count")).toBe(42);
});
it("useStateValue reads from state", () => {
const value = renderWithState(() => useStateValue("/name"), {
name: "Alice",
});
expect(value()).toBe("Alice");
});
it("useStateBinding returns value and setter", () => {
const [value, setValue] = renderWithState(() => useStateBinding("/x"), {
x: 1,
});
expect(value()).toBe(1);
expect(typeof setValue).toBe("function");
});
it("useStateValue stays reactive after state updates", () => {
const observed: Array<string | undefined> = [];
function TestComponent(): JSX.Element {
const value = useStateValue<string>("/name");
createEffect(() => {
observed.push(value());
});
const store = useStateStore();
return (
<button
data-testid="update"
onClick={() => store.set("/name", "Bob")}
/>
) as unknown as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ name: "Alice" }}>
<TestComponent />
</StateProvider>
));
expect(observed).toEqual(["Alice"]);
getByTestId("update").click();
expect(observed).toEqual(["Alice", "Bob"]);
});
it("useStateBinding value accessor stays reactive after state updates", () => {
const observed: Array<number | undefined> = [];
function TestComponent(): JSX.Element {
const [value, setValue] = useStateBinding<number>("/count");
createEffect(() => {
observed.push(value());
});
return (
<button data-testid="update" onClick={() => setValue(2)} />
) as unknown as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ count: 1 }}>
<TestComponent />
</StateProvider>
));
expect(observed).toEqual([1]);
getByTestId("update").click();
expect(observed).toEqual([1, 2]);
});
it("provides initial state to consumers", () => {
const ctx = renderWithState(() => useStateStore(), { a: 1, b: "hello" });
expect(ctx.state).toEqual({ a: 1, b: "hello" });
});
it("provides empty object when no initial state", () => {
const ctx = renderWithState(() => useStateStore());
expect(ctx.state).toEqual({});
});
it("get() retrieves values by path", () => {
const ctx = renderWithState(() => useStateStore(), {
user: { name: "Bob" },
});
expect(ctx.get("/user/name")).toBe("Bob");
});
it("get() returns undefined for missing path", () => {
const ctx = renderWithState(() => useStateStore(), {
user: { name: "Bob" },
});
expect(ctx.get("/user/age")).toBeUndefined();
});
it("set() updates values at path", () => {
const ctx = renderWithState(() => useStateStore(), { x: 0 });
ctx.set("/x", 42);
expect(ctx.get("/x")).toBe(42);
});
it("set() creates nested paths", () => {
const ctx = renderWithState(() => useStateStore());
ctx.set("/a/b/c", "deep");
expect(ctx.get("/a/b/c")).toBe("deep");
});
it("set() calls onStateChange callback", () => {
const onChange = vi.fn();
const ctx = renderWithState(
() => useStateStore(),
{},
{ onStateChange: onChange },
);
ctx.set("/name", "Alice");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
});
it("update() handles multiple values at once", () => {
const ctx = renderWithState(() => useStateStore());
ctx.update({ "/a": 1, "/b": "hello" });
expect(ctx.get("/a")).toBe(1);
expect(ctx.get("/b")).toBe("hello");
});
it("update() calls onStateChange with all changes", () => {
const onChange = vi.fn();
const ctx = renderWithState(
() => useStateStore(),
{},
{ onStateChange: onChange },
);
ctx.update({ "/a": 1, "/b": 2 });
expect(onChange).toHaveBeenCalledOnce();
const [changes] = onChange.mock.calls[0]!;
expect(changes).toEqual(
expect.arrayContaining([
{ path: "/a", value: 1 },
{ path: "/b", value: 2 },
]),
);
});
it("handles deeply nested state paths", () => {
const ctx = renderWithState(() => useStateStore());
ctx.set("/a/b/c/d", "nested");
expect(ctx.get("/a/b/c/d")).toBe("nested");
});
it("controlled mode: reads/writes through external StateStore", () => {
const store = createStateStore({ x: 10 });
const ctx = renderWithState(() => useStateStore(), {}, { store });
expect(ctx.get("/x")).toBe(10);
ctx.set("/x", 99);
expect(store.getSnapshot()).toEqual({ x: 99 });
store.set("/x", 200);
expect(store.get("/x")).toBe(200);
});
});
-208
View File
@@ -1,208 +0,0 @@
import {
createContext,
useContext,
createSignal,
createEffect,
createMemo,
onCleanup,
type Accessor,
type ParentProps,
} from "solid-js";
import {
getByPath,
createStateStore,
type StateModel,
type StateStore,
} from "@json-render/core";
import { flattenToPointers } from "@json-render/core/store-utils";
export interface StateContextValue {
state: StateModel;
get: (path: string) => unknown;
set: (path: string, value: unknown) => void;
update: (updates: Record<string, unknown>) => void;
getSnapshot: () => StateModel;
subscribeChanges: (
listener: (changes: Array<{ path: string; value: unknown }>) => void,
) => () => void;
}
const StateContext = createContext<StateContextValue | null>(null);
export interface StateProviderProps {
store?: StateStore;
initialState?: StateModel;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
function computeInitialFlat(
isControlled: boolean,
initialState: StateModel,
): Record<string, unknown> | null {
if (isControlled) return null;
if (Object.keys(initialState).length === 0) return {};
return flattenToPointers(initialState);
}
export function StateProvider(props: ParentProps<StateProviderProps>) {
let internalStore: StateStore | undefined;
if (!props.store) {
internalStore = createStateStore(props.initialState ?? {});
}
const store = () => props.store ?? internalStore!;
const initialMode = props.store ? "controlled" : "uncontrolled";
let modeWarned = false;
if (
typeof globalThis !== "undefined" &&
(globalThis as any).process?.env?.NODE_ENV !== "production"
) {
createEffect(() => {
const currentMode = props.store ? "controlled" : "uncontrolled";
if (currentMode !== initialMode && !modeWarned) {
modeWarned = true;
console.warn(
`StateProvider: switching from ${initialMode} to ${currentMode} mode is not supported.`,
);
}
});
}
let prevInitialState = props.initialState;
let prevFlat: Record<string, unknown> | null = computeInitialFlat(
!!props.store,
props.initialState ?? {},
);
createEffect(() => {
if (props.store) return;
const initialState = props.initialState ?? {};
if (initialState === prevInitialState) return;
prevInitialState = initialState;
const nextFlat =
initialState && Object.keys(initialState).length > 0
? flattenToPointers(initialState)
: {};
const prevFlatObj = prevFlat ?? {};
const allKeys = new Set([
...Object.keys(prevFlatObj),
...Object.keys(nextFlat),
]);
const updates: Record<string, unknown> = {};
for (const key of allKeys) {
if (prevFlatObj[key] !== nextFlat[key]) {
updates[key] = key in nextFlat ? nextFlat[key] : undefined;
}
}
prevFlat = nextFlat;
if (Object.keys(updates).length > 0) {
store().update(updates);
}
});
const [state, setState] = createSignal<StateModel>(store().getSnapshot(), {
equals: false,
});
const changeListeners = new Set<
(changes: Array<{ path: string; value: unknown }>) => void
>();
const subscribeChanges = (
listener: (changes: Array<{ path: string; value: unknown }>) => void,
) => {
changeListeners.add(listener);
return () => {
changeListeners.delete(listener);
};
};
const notifyChanges = (changes: Array<{ path: string; value: unknown }>) => {
for (const listener of changeListeners) {
listener(changes);
}
};
createEffect(() => {
const s = store();
setState(s.getSnapshot());
const unsubscribe = s.subscribe(() => {
setState(s.getSnapshot());
});
onCleanup(unsubscribe);
});
const set = (path: string, value: unknown) => {
const s = store();
const prev = s.getSnapshot();
const prevValue = getByPath(prev, path);
s.set(path, value);
if (prevValue !== value) {
const changes = [{ path, value }];
notifyChanges(changes);
if (!props.store && s.getSnapshot() !== prev) {
props.onStateChange?.(changes);
}
}
};
const update = (updates: Record<string, unknown>) => {
const s = store();
const prev = s.getSnapshot();
s.update(updates);
const changes: Array<{ path: string; value: unknown }> = [];
for (const [path, value] of Object.entries(updates)) {
if (getByPath(prev, path) !== value) {
changes.push({ path, value });
}
}
if (changes.length > 0) {
notifyChanges(changes);
if (!props.store && s.getSnapshot() !== prev) {
props.onStateChange?.(changes);
}
}
};
const get = (path: string) => store().get(path);
const getSnapshot = () => store().getSnapshot();
const ctx: StateContextValue = {
get state() {
return state();
},
get,
set,
update,
getSnapshot,
subscribeChanges,
};
return (
<StateContext.Provider value={ctx}>{props.children}</StateContext.Provider>
);
}
export function useStateStore(): StateContextValue {
const ctx = useContext(StateContext);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
export function useStateValue<T>(path: string): Accessor<T | undefined> {
const store = useStateStore();
return createMemo(() => getByPath(store.state, path) as T | undefined);
}
export function useStateBinding<T>(
path: string,
): [Accessor<T | undefined>, (value: T) => void] {
const store = useStateStore();
const value = createMemo(() => getByPath(store.state, path) as T | undefined);
const setValue = (newValue: T) => store.set(path, newValue);
return [value, setValue];
}
@@ -1,194 +0,0 @@
import { describe, it, expect } from "vitest";
import { render } from "@solidjs/testing-library";
import { createEffect, type JSX } from "solid-js";
import { StateProvider } from "./state";
import {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
} from "./validation";
function withProviders<T>(
hook: () => T,
initialState: Record<string, unknown> = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
return result;
}
function withStateOnly<T>(hook: () => T): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={{}}>
<TestComponent />
</StateProvider>
));
return result;
}
function withBothContexts(
initialState: Record<string, unknown> = {},
fieldPath: string = "/name",
config?: { checks: Array<{ type: string; message: string }> },
) {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
function TestComponent(): JSX.Element {
validationCtx = useValidation();
fieldCtx = useFieldValidation(fieldPath, config);
return (<div />) as JSX.Element;
}
render(() => (
<StateProvider initialState={initialState}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
return { validationCtx, fieldCtx };
}
describe("ValidationProvider — provide/inject", () => {
it("useValidation() throws outside a provider", () => {
expect(() => useValidation()).toThrow(
"useValidation must be used within a ValidationProvider",
);
});
});
describe("useOptionalValidation", () => {
it("returns null outside ValidationProvider", () => {
const result = withStateOnly(() => useOptionalValidation());
expect(result).toBeNull();
});
it("returns context inside ValidationProvider", () => {
const result = withProviders(() => useOptionalValidation());
expect(result).not.toBeNull();
expect(typeof result!.validate).toBe("function");
expect(typeof result!.validateAll).toBe("function");
});
});
describe("useFieldValidation — lifecycle", () => {
it("validate() with empty required field returns valid:false and errors", () => {
const { fieldCtx } = withBothContexts({ name: "" }, "/name", {
checks: [{ type: "required", message: "Name is required" }],
});
const result = fieldCtx.validate();
expect(result.valid).toBe(false);
expect(result.errors).toContain("Name is required");
expect(fieldCtx.errors()).toContain("Name is required");
expect(fieldCtx.isValid()).toBe(false);
});
it("validate() with valid value returns valid:true and no errors", () => {
const { fieldCtx } = withBothContexts({ name: "Alice" }, "/name", {
checks: [{ type: "required", message: "Name is required" }],
});
const result = fieldCtx.validate();
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
expect(fieldCtx.errors()).toHaveLength(0);
expect(fieldCtx.isValid()).toBe(true);
});
it("touch() sets touched:true in fieldStates", () => {
const { validationCtx, fieldCtx } = withBothContexts({}, "/email");
fieldCtx.touch();
expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
});
it("clear() resets field state from validation context", () => {
const { validationCtx, fieldCtx } = withBothContexts(
{ email: "" },
"/email",
{ checks: [{ type: "required", message: "Required" }] },
);
fieldCtx.validate();
fieldCtx.clear();
expect(validationCtx.fieldStates["/email"]).toBeUndefined();
});
it("useFieldValidation accessors stay reactive after validate/touch", () => {
const observedErrors: string[][] = [];
const observedTouched: boolean[] = [];
function TestComponent(): JSX.Element {
const field = useFieldValidation("/email", {
checks: [{ type: "required", message: "Required" }],
});
createEffect(() => {
observedErrors.push(field.errors());
});
createEffect(() => {
observedTouched.push(field.state().touched);
});
return (
<>
<button data-testid="validate" onClick={() => field.validate()} />
<button data-testid="touch" onClick={() => field.touch()} />
</>
) as JSX.Element;
}
const { getByTestId } = render(() => (
<StateProvider initialState={{ email: "" }}>
<ValidationProvider>
<TestComponent />
</ValidationProvider>
</StateProvider>
));
expect(observedErrors).toEqual([[]]);
expect(observedTouched).toEqual([false]);
getByTestId("validate").click();
expect(observedErrors).toEqual([[], ["Required"]]);
expect(observedTouched).toEqual([false, true]);
getByTestId("touch").click();
expect(observedTouched).toEqual([false, true, true]);
});
});
describe("validateAll", () => {
it("returns true when all registered fields pass", () => {
const { validationCtx } = withBothContexts({ name: "Alice" }, "/name", {
checks: [{ type: "required", message: "Required" }],
});
expect(validationCtx.validateAll()).toBe(true);
});
it("returns false when any field fails", () => {
const { validationCtx } = withBothContexts({ name: "" }, "/name", {
checks: [{ type: "required", message: "Required" }],
});
expect(validationCtx.validateAll()).toBe(false);
});
});
-263
View File
@@ -1,263 +0,0 @@
import {
createContext,
useContext,
createSignal,
createEffect,
createMemo,
type Accessor,
type ParentProps,
} from "solid-js";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
const ValidationContext = createContext<ValidationContextValue | null>(null);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
}
function dynamicArgsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const va = a[key];
const vb = b[key];
if (va === vb) continue;
if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
const sa = (va as Record<string, unknown>).$state;
const sb = (vb as Record<string, unknown>).$state;
if (typeof sa === "string" && sa === sb) continue;
}
return false;
}
return true;
}
function validationConfigEqual(
a: ValidationConfig,
b: ValidationConfig,
): boolean {
if (a === b) return true;
if (a.validateOn !== b.validateOn) return false;
const ac = a.checks ?? [];
const bc = b.checks ?? [];
if (ac.length !== bc.length) return false;
for (let i = 0; i < ac.length; i++) {
const ca = ac[i]!;
const cb = bc[i]!;
if (ca.type !== cb.type) return false;
if (ca.message !== cb.message) return false;
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
}
return true;
}
export function ValidationProvider(
props: ParentProps<ValidationProviderProps>,
) {
const { getSnapshot } = useStateStore();
const customFunctions = () => props.customFunctions ?? {};
const [fieldStates, setFieldStates] = createSignal<
Record<string, FieldValidationState>
>({});
let fieldStatesRef: Record<string, FieldValidationState> = {};
const [fieldConfigs, setFieldConfigs] = createSignal<
Record<string, ValidationConfig>
>({});
const registerField = (path: string, config: ValidationConfig) => {
setFieldConfigs((prev) => {
const existing = prev[path];
if (existing && validationConfigEqual(existing, config)) {
return prev;
}
return { ...prev, [path]: config };
});
};
const validate = (
path: string,
config: ValidationConfig,
): ValidationResult => {
const currentState = getSnapshot();
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: currentState,
customFunctions: customFunctions(),
});
const newFieldState: FieldValidationState = {
touched: fieldStatesRef[path]?.touched ?? true,
validated: true,
result,
};
fieldStatesRef = {
...fieldStatesRef,
[path]: newFieldState,
};
setFieldStates(fieldStatesRef);
return result;
};
const touch = (path: string) => {
fieldStatesRef = {
...fieldStatesRef,
[path]: {
...fieldStatesRef[path],
touched: true,
validated: fieldStatesRef[path]?.validated ?? false,
result: fieldStatesRef[path]?.result ?? null,
},
};
setFieldStates(fieldStatesRef);
};
const clear = (path: string) => {
const { [path]: _, ...rest } = fieldStatesRef;
fieldStatesRef = rest;
setFieldStates(rest);
};
const validateAll = () => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs())) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
};
const value: ValidationContextValue = {
get customFunctions() {
return customFunctions();
},
get fieldStates() {
fieldStates();
return fieldStatesRef;
},
validate,
touch,
clear,
validateAll,
registerField,
};
return (
<ValidationContext.Provider value={value}>
{props.children}
</ValidationContext.Provider>
);
}
export function useValidation(): ValidationContextValue {
const ctx = useContext(ValidationContext);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
export function useOptionalValidation(): ValidationContextValue | null {
return useContext(ValidationContext);
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: Accessor<FieldValidationState>;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: Accessor<string[]>;
isValid: Accessor<boolean>;
} {
const validation = useValidation();
createEffect(() => {
if (path && config) {
validation.registerField(path, config);
}
});
const state = createMemo<FieldValidationState>(() => {
const current = validation.fieldStates[path];
return (
current ?? {
touched: false,
validated: false,
result: null,
}
);
});
const validate = () => validation.validate(path, config ?? { checks: [] });
const touch = () => validation.touch(path);
const clear = () => validation.clear(path);
const errors = createMemo(() => state().result?.errors ?? []);
const isValid = createMemo(() => state().result?.valid ?? true);
return {
state,
validate,
touch,
clear,
errors,
isValid,
};
}
@@ -1,94 +0,0 @@
import { describe, it, expect } from "vitest";
import { render } from "@solidjs/testing-library";
import type { JSX } from "solid-js";
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
import { StateProvider } from "./state";
function renderWithVisibility<T>(
hook: () => T,
data: Record<string, unknown> = {},
): T {
let result!: T;
function TestComponent(): JSX.Element {
result = hook();
return (<div />) as unknown as JSX.Element;
}
render(() => (
<StateProvider initialState={data}>
<VisibilityProvider>
<TestComponent />
</VisibilityProvider>
</StateProvider>
));
return result;
}
describe("useVisibility", () => {
it("provides isVisible function", () => {
const ctx = renderWithVisibility(() => useVisibility());
expect(typeof ctx.isVisible).toBe("function");
});
it("provides visibility context with stateModel", () => {
const ctx = renderWithVisibility(() => useVisibility(), { test: true });
expect(ctx.ctx.stateModel).toEqual({ test: true });
});
});
describe("useIsVisible", () => {
it("returns true for undefined condition", () => {
const result = renderWithVisibility(() => useIsVisible(undefined));
expect(result).toBe(true);
});
it("returns true for true condition", () => {
const result = renderWithVisibility(() => useIsVisible(true));
expect(result).toBe(true);
});
it("returns false for false condition", () => {
const result = renderWithVisibility(() => useIsVisible(false));
expect(result).toBe(false);
});
it("evaluates $state conditions against data", () => {
const trueResult = renderWithVisibility(
() => useIsVisible({ $state: "/isVisible" }),
{ isVisible: true },
);
expect(trueResult).toBe(true);
const falseResult = renderWithVisibility(
() => useIsVisible({ $state: "/isVisible" }),
{ isVisible: false },
);
expect(falseResult).toBe(false);
});
it("evaluates equality conditions", () => {
const result = renderWithVisibility(
() => useIsVisible({ $state: "/count", eq: 1 }),
{ count: 1 },
);
expect(result).toBe(true);
});
it("evaluates array conditions (implicit AND)", () => {
const result = renderWithVisibility(
() =>
useIsVisible([
{ $state: "/user/isAdmin" },
{ $state: "/count", eq: 5 },
]),
{ user: { isAdmin: true }, count: 5 },
);
expect(result).toBe(true);
});
});
@@ -1,53 +0,0 @@
import { createContext, useContext, type ParentProps } from "solid-js";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface VisibilityContextValue {
isVisible: (condition: VisibilityCondition | undefined) => boolean;
ctx: CoreVisibilityContext;
}
const VisibilityContext = createContext<VisibilityContextValue | null>(null);
export type VisibilityProviderProps = ParentProps;
export function VisibilityProvider(props: VisibilityProviderProps) {
const stateStore = useStateStore();
const visibilityCtx: CoreVisibilityContext = {
get stateModel() {
return stateStore.state;
},
};
const value: VisibilityContextValue = {
isVisible: (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, visibilityCtx),
ctx: visibilityCtx,
};
return (
<VisibilityContext.Provider value={value}>
{props.children}
</VisibilityContext.Provider>
);
}
export function useVisibility(): VisibilityContextValue {
const ctx = useContext(VisibilityContext);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
export function useIsVisible(
condition: VisibilityCondition | undefined,
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
-908
View File
@@ -1,908 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, waitFor } from "@solidjs/testing-library";
import { createSignal } from "solid-js";
import { defineCatalog, type Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
defineRegistry,
type ComponentRenderProps,
} from "./renderer";
import type { ComponentFn } from "./catalog-types";
import { useStateStore } from "./contexts/state";
import { useFieldValidation } from "./contexts/validation";
import { useBoundProp } from "./hooks";
import { schema as solidSchema } from "./schema";
import { z } from "zod";
const exampleCatalog = defineCatalog(solidSchema, {
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
RendererTabs: {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue, React, Svelte, and Solid renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
slots: [],
description: "Badge indicating which renderer is currently active",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: {
params: z.object({}),
description: "Reset the counter to 0",
},
toggleItem: {
params: z.object({ index: z.number() }),
description: "Toggle the completed state of a todo item",
},
switchToVue: {
params: z.object({}),
description: "Switch to the Vue renderer",
},
switchToReact: {
params: z.object({}),
description: "Switch to the React renderer",
},
switchToSvelte: {
params: z.object({}),
description: "Switch to the Svelte renderer",
},
switchToSolid: {
params: z.object({}),
description: "Switch to the Solid renderer",
},
},
});
const definedStack: ComponentFn<typeof exampleCatalog, "Stack"> = (ctx) => (
<div>{ctx.children}</div>
);
const definedButton: ComponentFn<typeof exampleCatalog, "Button"> = (ctx) => (
<button data-testid="defined-btn" onClick={() => ctx.emit("press")}>
{ctx.props.label}
</button>
);
const definedText: ComponentFn<typeof exampleCatalog, "Text"> = (ctx) => (
<span data-testid="defined-text">{ctx.props.content}</span>
);
const definedCard: ComponentFn<typeof exampleCatalog, "Card"> = (ctx) => (
<div>{ctx.children}</div>
);
const definedBadge: ComponentFn<typeof exampleCatalog, "Badge"> = (ctx) => (
<span>{ctx.props.label}</span>
);
const definedListItem: ComponentFn<typeof exampleCatalog, "ListItem"> = (
ctx,
) => <div>{ctx.props.title}</div>;
const definedRendererBadge: ComponentFn<
typeof exampleCatalog,
"RendererBadge"
> = (ctx) => <span>{ctx.props.renderer}</span>;
const definedRendererTabs: ComponentFn<
typeof exampleCatalog,
"RendererTabs"
> = () => <div />;
const exampleComponents = {
Stack: definedStack,
Button: definedButton,
Text: definedText,
Card: definedCard,
Badge: definedBadge,
ListItem: definedListItem,
RendererBadge: definedRendererBadge,
RendererTabs: definedRendererTabs,
};
function Button(props: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => props.emit("press")}>
{props.element.props.label}
</button>
);
}
function Text(props: ComponentRenderProps<{ text: unknown }>) {
const display = () => {
const v = props.element.props.text;
if (v == null) return "";
return typeof v === "string" ? v : JSON.stringify(v);
};
return <span data-testid="text">{display()}</span>;
}
function InputField(
props: ComponentRenderProps<{
label?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
}>,
) {
const elementProps = () => props.element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
elementProps().value as string | undefined,
props.bindings?.value,
);
const [localValue, setLocalValue] = createSignal("");
const isBound = () => !!props.bindings?.value;
const value = () => (isBound() ? (boundValue ?? "") : localValue());
const setValue = (v: string) =>
isBound() ? setBoundValue(v) : setLocalValue(v);
const hasValidation = () =>
!!(props.bindings?.value && elementProps().checks?.length);
const config = () =>
hasValidation() ? { checks: elementProps().checks ?? [] } : undefined;
const { errors } = useFieldValidation(props.bindings?.value ?? "", config());
return (
<div>
{elementProps().label && <label>{elementProps().label}</label>}
<input
data-testid="input"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
/>
{errors().length > 0 && (
<span data-testid="input-error">{errors()[0]}</span>
)}
</div>
);
}
function SelectField(
props: ComponentRenderProps<{ label?: string; value?: string }>,
) {
const [boundValue] = useBoundProp<string>(
props.element.props.value as string | undefined,
props.bindings?.value,
);
return <span data-testid="select-value">{boundValue ?? ""}</span>;
}
function ValidatedSelect(
props: ComponentRenderProps<{
label?: string;
name?: string;
options?: string[];
placeholder?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
validateOn?: "change" | "blur" | "submit";
}>,
) {
const elementProps = () => props.element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
elementProps().value as string | undefined,
props.bindings?.value,
);
const [localValue, setLocalValue] = createSignal("");
const isBound = () => !!props.bindings?.value;
const value = () => (isBound() ? (boundValue ?? "") : localValue());
const setValue = (v: string) =>
isBound() ? setBoundValue(v) : setLocalValue(v);
const validateOn = () => elementProps().validateOn ?? "change";
const hasValidation = () =>
!!(props.bindings?.value && elementProps().checks?.length);
const config = () =>
hasValidation()
? { checks: elementProps().checks ?? [], validateOn: validateOn() }
: undefined;
const { errors, validate } = useFieldValidation(
props.bindings?.value ?? "",
config(),
);
const options = () => elementProps().options ?? [];
const name = () => elementProps().name ?? "default";
return (
<div>
{elementProps().label && <label>{elementProps().label}</label>}
<select
data-testid={`select-${name()}`}
value={value()}
onChange={(e) => {
setValue(e.currentTarget.value);
if (hasValidation() && validateOn() === "change") validate();
props.emit("change");
}}
>
<option value="">{elementProps().placeholder ?? "Select..."}</option>
{options().map((opt) => (
<option value={opt}>{opt}</option>
))}
</select>
{errors().length > 0 && (
<span data-testid={`select-error-${name()}`}>{errors()[0]}</span>
)}
</div>
);
}
function Stack(props: ComponentRenderProps<Record<string, unknown>>) {
return <div data-testid="stack">{props.children}</div>;
}
let probeGetSnapshot: (() => Record<string, unknown>) | undefined;
function StateProbe() {
const ctx = useStateStore();
probeGetSnapshot = ctx.getSnapshot;
return <div data-testid="state-probe" />;
}
const registry = { Button, Text, Input: InputField, Select: SelectField };
function getState(): Record<string, unknown> {
return probeGetSnapshot!();
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
render(() => (
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={functions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// $template expressions in rendering
// =============================================================================
describe("$template expressions in rendering", () => {
it("interpolates state values into a template string", () => {
const spec: Spec = {
state: { user: { name: "Alice" }, count: 3 },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$template: "Hello, ${/user/name}! You have ${/count} messages.",
},
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe(
"Hello, Alice! You have 3 messages.",
);
});
it("resolves missing paths to empty string", () => {
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $template: "Hi ${/name}!" },
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
));
expect(screen.getByTestId("text").textContent).toBe("Hi !");
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
probeGetSnapshot = undefined;
const loadCities = vi.fn(async (_params: Record<string, unknown>) => {});
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
render(() => (
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ loadCities }}
>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
expect(loadCities).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
expect(loadCities).toHaveBeenCalledTimes(1);
});
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
probeGetSnapshot = undefined;
const action1 = vi.fn();
const action2 = vi.fn();
const reg = { ...registry, Stack };
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ action1, action2 }}
>
<Renderer spec={spec} registry={reg} />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
});
describe("defineRegistry reactivity", () => {
it("updates $state-backed props after setState actions", async () => {
const { registry: definedRegistry } = defineRegistry(exampleCatalog, {
components: exampleComponents,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
switchToSvelte: async () => {},
switchToSolid: async () => {},
},
});
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "text"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: {
action: "setState",
params: { statePath: "/value", value: "b" },
},
},
children: [],
},
text: {
type: "Text",
props: { content: { $state: "/value" } },
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={definedRegistry} initialState={spec.state}>
<Renderer spec={spec} registry={definedRegistry} />
</JSONUIProvider>
));
expect(screen.getByTestId("defined-text").textContent).toBe("a");
fireEvent.click(screen.getByTestId("defined-btn"));
await waitFor(() => {
expect(screen.getByTestId("defined-text").textContent).toBe("b");
});
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false } when a required field is empty", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
});
it("writes { valid: true } when all fields pass validation", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.result).toEqual({ valid: true, errors: {} });
});
});
it("defaults to /formValidation when no statePath is provided", async () => {
probeGetSnapshot = undefined;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.click(screen.getByTestId("btn"));
await waitFor(() => {
const state = getState();
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
});
// =============================================================================
// Select validate-on-change timing (#151)
// =============================================================================
describe("Select validate-on-change sees the new value, not the stale value", () => {
const regWithSelect = {
...registry,
Stack,
Select: ValidatedSelect,
};
it("does not show 'required' error when selecting the first value", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: { form: { country: "" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["countrySelect"],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.change(screen.getByTestId("select-country"), {
target: { value: "US" },
});
await waitFor(() => {
const state = getState();
expect((state.form as Record<string, unknown>).country).toBe("US");
});
expect(screen.queryByTestId("select-error-country")).toBeNull();
});
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
probeGetSnapshot = undefined;
const spec: Spec = {
state: {
form: { country: "US", city: "" },
availableCities: ["New York", "Chicago"],
},
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["citySelect"],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: ["New York", "Chicago"],
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(() => (
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>
));
fireEvent.change(screen.getByTestId("select-city"), {
target: { value: "New York" },
});
await waitFor(() => {
const state = getState();
expect((state.form as Record<string, unknown>).city).toBe("New York");
});
expect(screen.queryByTestId("select-error-city")).toBeNull();
});
});
-430
View File
@@ -1,430 +0,0 @@
import { describe, it, expect } from "vitest";
import { flatToTree, buildSpecFromParts, getTextFromParts } from "./hooks";
describe("flatToTree", () => {
it("converts array of elements to tree structure", () => {
const elements = [
{ key: "container", type: "stack", props: {}, parentKey: null },
{
key: "text1",
type: "text",
props: { content: "Hello" },
parentKey: "container",
},
{
key: "text2",
type: "text",
props: { content: "World" },
parentKey: "container",
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("container");
expect(Object.keys(tree.elements)).toHaveLength(3);
expect(tree.elements["container"]).toBeDefined();
expect(tree.elements["text1"]).toBeDefined();
expect(tree.elements["text2"]).toBeDefined();
});
it("builds parent-child relationships", () => {
const elements = [
{ key: "root", type: "stack", props: {}, parentKey: null },
{ key: "child1", type: "text", props: {}, parentKey: "root" },
{ key: "child2", type: "text", props: {}, parentKey: "root" },
];
const tree = flatToTree(elements);
expect(tree.elements["root"]?.children).toHaveLength(2);
expect(tree.elements["root"]?.children).toContain("child1");
expect(tree.elements["root"]?.children).toContain("child2");
});
it("handles single root element", () => {
const elements = [
{
key: "only",
type: "text",
props: { content: "Single" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.root).toBe("only");
expect(Object.keys(tree.elements)).toHaveLength(1);
});
it("handles deeply nested elements", () => {
const elements = [
{ key: "level0", type: "stack", props: {}, parentKey: null },
{ key: "level1", type: "stack", props: {}, parentKey: "level0" },
{ key: "level2", type: "stack", props: {}, parentKey: "level1" },
{ key: "level3", type: "text", props: {}, parentKey: "level2" },
];
const tree = flatToTree(elements);
expect(tree.root).toBe("level0");
expect(tree.elements["level0"]?.children).toContain("level1");
expect(tree.elements["level1"]?.children).toContain("level2");
expect(tree.elements["level2"]?.children).toContain("level3");
});
it("preserves element props", () => {
const elements = [
{
key: "btn",
type: "button",
props: { label: "Click me", variant: "primary" },
parentKey: null,
},
];
const tree = flatToTree(elements);
expect(tree.elements["btn"]?.props).toEqual({
label: "Click me",
variant: "primary",
});
});
it("preserves visibility conditions", () => {
const elements = [
{
key: "conditional",
type: "text",
props: {},
parentKey: null,
visible: { $state: "/isVisible" },
},
];
const tree = flatToTree(elements);
expect(tree.elements["conditional"]?.visible).toEqual({
$state: "/isVisible",
});
});
it("handles elements with undefined parentKey as root", () => {
const elements = [
{ key: "root", type: "stack", props: {} } as {
key: string;
type: string;
props: Record<string, unknown>;
parentKey?: string | null;
},
];
const tree = flatToTree(elements);
// Elements without parentKey should not become root (only null parentKey)
// This tests the edge case
expect(tree.elements["root"]).toBeDefined();
});
it("handles empty elements array", () => {
const tree = flatToTree([]);
expect(tree.root).toBe("");
expect(Object.keys(tree.elements)).toHaveLength(0);
});
it("handles multiple children correctly", () => {
const elements = [
{ key: "parent", type: "grid", props: {}, parentKey: null },
{ key: "a", type: "card", props: {}, parentKey: "parent" },
{ key: "b", type: "card", props: {}, parentKey: "parent" },
{ key: "c", type: "card", props: {}, parentKey: "parent" },
{ key: "d", type: "card", props: {}, parentKey: "parent" },
];
const tree = flatToTree(elements);
expect(tree.elements["parent"]?.children).toHaveLength(4);
expect(tree.elements["parent"]?.children).toEqual(["a", "b", "c", "d"]);
});
});
// =============================================================================
// buildSpecFromParts
// =============================================================================
describe("buildSpecFromParts", () => {
it("returns null when no data-spec parts are present", () => {
const parts = [
{ type: "text", text: "Hello there" },
{ type: "text", text: "How can I help?" },
];
expect(buildSpecFromParts(parts)).toBeNull();
});
it("builds a spec from patch parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Card", props: { title: "Hello" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
expect(spec!.elements.main).toEqual({
type: "Card",
props: { title: "Hello" },
children: [],
});
});
it("handles flat spec parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": { type: "Card", props: {}, children: [] },
},
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("card-1");
expect(spec!.elements["card-1"]).toBeDefined();
});
it("ignores non-spec parts", () => {
const parts = [
{ type: "text", text: "Some text" },
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{ type: "tool-invocation", data: { toolName: "search" } },
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBe("main");
});
it("applies patches incrementally", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Stack", props: {}, children: ["child"] },
},
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/child",
value: { type: "Text", props: { content: "Hi" }, children: [] },
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(Object.keys(spec!.elements)).toHaveLength(2);
expect(spec!.elements.child!.props.content).toBe("Hi");
});
it("handles nested spec parts via nestedToFlat", () => {
const parts = [
{
type: "data-spec",
data: {
type: "nested",
spec: {
type: "Card",
props: { title: "Nested" },
children: [
{ type: "Text", props: { content: "Child" }, children: [] },
],
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
expect(spec!.root).toBeTruthy();
// nestedToFlat generates keys like el-0, el-1
const elementKeys = Object.keys(spec!.elements);
expect(elementKeys.length).toBe(2);
const rootEl = spec?.elements[spec.root];
expect(rootEl).toBeDefined();
expect(rootEl?.type).toBe("Card");
expect(rootEl?.props.title).toBe("Nested");
expect(rootEl?.children).toHaveLength(1);
const childKey = rootEl?.children?.[0];
const childEl = childKey ? spec?.elements[childKey] : undefined;
expect(childEl).toBeDefined();
expect(childEl?.type).toBe("Text");
expect(childEl?.props.content).toBe("Child");
});
it("handles mixed patch + flat + nested parts in sequence", () => {
const parts = [
// Start with a patch
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "main" },
},
},
{
type: "data-spec",
data: {
type: "patch",
patch: {
op: "add",
path: "/elements/main",
value: { type: "Stack", props: {}, children: [] },
},
},
},
// Then a flat spec overwrites everything
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Flat" },
children: [],
},
},
},
},
},
];
const spec = buildSpecFromParts(parts);
expect(spec).not.toBeNull();
// Flat overwrites root and elements
expect(spec!.root).toBe("card-1");
expect(spec!.elements["card-1"]).toBeDefined();
expect(spec!.elements["card-1"]!.type).toBe("Card");
});
it("returns empty elements map from empty parts list", () => {
const spec = buildSpecFromParts([]);
expect(spec).toBeNull();
});
});
// =============================================================================
// getTextFromParts
// =============================================================================
describe("getTextFromParts", () => {
it("extracts text from text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("returns empty string when no text parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "x" },
},
},
];
expect(getTextFromParts(parts)).toBe("");
});
it("ignores non-text parts", () => {
const parts = [
{ type: "text", text: "Before" },
{ type: "data-spec", data: {} },
{ type: "tool-invocation", data: {} },
{ type: "text", text: "After" },
];
expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
});
it("trims whitespace from text parts", () => {
const parts = [
{ type: "text", text: " Hello " },
{ type: "text", text: " World " },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("skips empty text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: " " },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("ignores text parts with non-string text field", () => {
const parts = [
{ type: "text", text: "Valid" },
{ type: "text", text: undefined as unknown as string },
{ type: "text", text: 42 as unknown as string },
{ type: "text", text: "Also valid" },
];
expect(getTextFromParts(parts)).toBe("Valid\n\nAlso valid");
});
});
-881
View File
@@ -1,881 +0,0 @@
import { createSignal, onCleanup, createMemo } from "solid-js";
import type {
Spec,
UIElement,
FlatElement,
JsonPatch,
SpecDataPart,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
createMixedStreamParser,
applySpecPatch,
nestedToFlat,
SPEC_DATA_PART_TYPE,
} from "@json-render/core";
/**
* Token usage metadata from AI generation
*/
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
/**
* Parse result for a single line -- either a patch or usage metadata
*/
type ParsedLine =
| { type: "patch"; patch: JsonPatch }
| { type: "usage"; usage: TokenUsage }
| null;
/**
* Parse a single JSON line (patch or metadata)
*/
function parseLine(line: string): ParsedLine {
try {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) {
return null;
}
const parsed = JSON.parse(trimmed);
// Check for usage metadata
if (parsed.__meta === "usage") {
return {
type: "usage",
usage: {
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
},
};
}
return { type: "patch", patch: parsed as JsonPatch };
} catch {
return null;
}
}
/**
* Set a value at a spec path (for add/replace operations).
*/
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
if (path === "/root") {
newSpec.root = value as string;
return;
}
if (path === "/state") {
newSpec.state = value as Record<string, unknown>;
return;
}
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
const statePath = path.slice("/state".length); // e.g. "/posts"
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
newSpec.elements[elementKey] = value as UIElement;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
propPath,
value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Remove a value at a spec path.
*/
function removeSpecValue(newSpec: Spec, path: string): void {
if (path === "/state") {
delete newSpec.state;
return;
}
if (path.startsWith("/state/") && newSpec.state) {
const statePath = path.slice("/state".length);
removeByPath(newSpec.state as Record<string, unknown>, statePath);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
const { [elementKey]: _, ...rest } = newSpec.elements;
newSpec.elements = rest;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
removeByPath(
newElement as unknown as Record<string, unknown>,
propPath,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Get a value at a spec path.
*/
function getSpecValue(spec: Spec, path: string): unknown {
if (path === "/root") return spec.root;
if (path === "/state") return spec.state;
if (path.startsWith("/state/") && spec.state) {
const statePath = path.slice("/state".length);
return getByPath(spec.state as Record<string, unknown>, statePath);
}
return getByPath(spec as unknown as Record<string, unknown>, path);
}
/**
* Apply an RFC 6902 JSON patch to the current spec.
* Supports add, remove, replace, move, copy, and test operations.
*/
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace": {
setSpecValue(newSpec, patch.path, patch.value);
break;
}
case "remove": {
removeSpecValue(newSpec, patch.path);
break;
}
case "move": {
if (!patch.from) break;
const moveValue = getSpecValue(newSpec, patch.from);
removeSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, moveValue);
break;
}
case "copy": {
if (!patch.from) break;
const copyValue = getSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, copyValue);
break;
}
case "test": {
// test is a no-op for rendering purposes (validation only)
break;
}
}
return newSpec;
}
/**
* Options for useUIStream
*/
export interface UseUIStreamOptions {
/** API endpoint */
api: string;
/** Callback when complete */
onComplete?: (spec: Spec) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useUIStream
*/
export interface UseUIStreamReturn {
/** Current UI spec */
readonly spec: Spec | null;
/** Whether currently streaming */
readonly isStreaming: boolean;
/** Error if any */
readonly error: Error | null;
/** Token usage from the last generation */
readonly usage: TokenUsage | null;
/** Raw JSONL lines received from the stream (JSON patch lines) */
readonly rawLines: string[];
/** Send a prompt to generate UI */
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
/** Clear the current spec */
clear: () => void;
}
/**
* Hook for streaming UI generation
*/
export function useUIStream(options: UseUIStreamOptions): UseUIStreamReturn {
const [spec, setSpec] = createSignal<Spec | null>(null);
const [isStreaming, setIsStreaming] = createSignal(false);
const [error, setError] = createSignal<Error | null>(null);
const [usage, setUsage] = createSignal<TokenUsage | null>(null);
const [rawLines, setRawLines] = createSignal<string[]>([]);
let abortController: AbortController | null = null;
const clear = () => {
setSpec(null);
setError(null);
};
const send = async (prompt: string, context?: Record<string, unknown>) => {
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
setIsStreaming(true);
setError(null);
setUsage(null);
setRawLines([]);
// Start with previous spec if provided, otherwise empty spec
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
: { root: "", elements: {} };
setSpec(currentSpec);
try {
const response = await fetch(options.api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
currentSpec,
}),
signal: abortController.signal,
});
if (!response.ok) {
// Try to parse JSON error response for better error messages
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors, use default message
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const result = parseLine(trimmed);
if (!result) continue;
if (result.type === "usage") {
setUsage(result.usage);
} else {
setRawLines((prev) => [...prev, trimmed]);
currentSpec = applyPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
}
// Process any remaining buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
if (result) {
if (result.type === "usage") {
setUsage(result.usage);
} else {
setRawLines((prev) => [...prev, trimmed]);
currentSpec = applyPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
}
options.onComplete?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
setError(resolvedError);
options.onError?.(resolvedError);
} finally {
setIsStreaming(false);
}
};
// Cleanup on disposal
onCleanup(() => {
abortController?.abort();
});
return {
get spec() {
return spec();
},
get isStreaming() {
return isStreaming();
},
get error() {
return error();
},
get usage() {
return usage();
},
get rawLines() {
return rawLines();
},
send,
clear,
};
}
/**
* Convert a flat element list to a Spec.
* Input elements use key/parentKey to establish identity and relationships.
* Output spec uses the map-based format where key is the map entry key
* and parent-child relationships are expressed through children arrays.
*/
export function flatToTree(elements: FlatElement[]): Spec {
const elementMap: Record<string, UIElement> = {};
let root = "";
// First pass: add all elements to map
for (const element of elements) {
elementMap[element.key] = {
type: element.type,
props: element.props,
children: [],
visible: element.visible,
};
}
// Second pass: build parent-child relationships
for (const element of elements) {
if (element.parentKey) {
const parent = elementMap[element.parentKey];
if (parent) {
if (!parent.children) {
parent.children = [];
}
parent.children.push(element.key);
}
} else {
root = element.key;
}
}
return { root, elements: elementMap };
}
// =============================================================================
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
// =============================================================================
// Re-export useStateStore access for useBoundProp without circular import
import { useStateStore as useStateStoreFromContext } from "./contexts/state";
/**
* Hook for two-way bound props. Returns `[value, setValue]` where:
*
* - `value` is the already-resolved prop value (passed through from render props)
* - `setValue` writes back to the bound state path (no-op if not bound)
*
* Designed to work with the `bindings` map that the renderer provides when
* a prop uses `{ $bindState: "/path" }` or `{ $bindItem: "field" }`.
*
* @example
* ```tsx
* import { useBoundProp } from "@json-render/solid";
*
* const Input: ComponentRenderer = (ctx) => {
* const [value, setValue] = useBoundProp<string>(ctx.props.value, ctx.bindings?.value);
* return <input value={value ?? ""} onInput={(e) => setValue(e.target.value)} />;
* };
* ```
*/
export function useBoundProp<T>(
propValue: T | undefined,
bindingPath: string | undefined,
): [T | undefined, (value: T) => void] {
const { set } = useStateStoreFromContext();
const setValue = (value: T) => {
if (bindingPath) set(bindingPath, value);
};
return [propValue, setValue];
}
// =============================================================================
// buildSpecFromParts — Derive Spec from AI SDK data parts
// =============================================================================
/**
* A single part from the AI SDK's `message.parts` array. This is a minimal
* structural type so that library helpers do not depend on the AI SDK.
* Fields are optional because different part types carry different data:
* - Text parts have `text`
* - Data parts have `data`
*/
export interface DataPart {
type: string;
text?: string;
data?: unknown;
}
/**
* Build a `Spec` by replaying all spec data parts from a message's
* parts array. Returns `null` if no spec data parts are present.
*
* This function is designed to work with the AI SDK's `UIMessage.parts` array.
* It picks out parts whose `type` is {@link SPEC_DATA_PART_TYPE} and processes them based
* on the payload's `type` discriminator:
*
* - `"patch"`: Applies the JSON Patch operation incrementally via `applySpecPatch`.
* - `"flat"`: Replaces the spec with the complete flat spec.
* - `"nested"`: Assigns the nested spec directly (future: nested-to-flat conversion).
*
* The function has no AI SDK dependency -- it operates on a generic array of
* `{ type: string; data: unknown }` objects.
*
* @example
* ```tsx
* const spec = buildSpecFromParts(message.parts);
* if (spec) {
* return <MyRenderer spec={spec} />;
* }
* ```
*/
/**
* Type guard that validates a data part payload looks like a valid
* {@link SpecDataPart} before we cast it. Returns `false` (and the
* part is silently skipped) for malformed payloads.
*/
function isSpecDataPart(data: unknown): data is SpecDataPart {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
switch (obj.type) {
case "patch":
return typeof obj.patch === "object" && obj.patch !== null;
case "flat":
case "nested":
return typeof obj.spec === "object" && obj.spec !== null;
default:
return false;
}
}
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
const spec: Spec = { root: "", elements: {} };
let hasSpec = false;
for (const part of parts) {
if (part.type === SPEC_DATA_PART_TYPE) {
if (!isSpecDataPart(part.data)) continue;
const payload = part.data;
if (payload.type === "patch") {
hasSpec = true;
applySpecPatch(spec, payload.patch);
} else if (payload.type === "flat") {
hasSpec = true;
Object.assign(spec, payload.spec);
} else if (payload.type === "nested") {
hasSpec = true;
const flat = nestedToFlat(payload.spec);
Object.assign(spec, flat);
}
}
}
return hasSpec ? spec : null;
}
/**
* Extract and join all text content from a message's parts array.
*
* Filters for parts with `type === "text"`, trims each one, and joins them
* with double newlines so that text from separate agent steps renders as
* distinct paragraphs in markdown.
*
* Has no AI SDK dependency -- operates on a generic `DataPart[]`.
*
* @example
* ```tsx
* const text = getTextFromParts(message.parts);
* if (text) {
* return <Streamdown>{text}</Streamdown>;
* }
* ```
*/
export function getTextFromParts(parts: DataPart[]): string {
return parts
.filter(
(p): p is DataPart & { text: string } =>
p.type === "text" && typeof p.text === "string",
)
.map((p) => p.text.trim())
.filter(Boolean)
.join("\n\n");
}
// =============================================================================
// useJsonRenderMessage — extract spec + text from message parts
// =============================================================================
/**
* Hook that extracts both the json-render spec and text content from a
* message's parts array. Combines `buildSpecFromParts` and `getTextFromParts`
* into a single call with memoized results.
*
* **Memoization behavior:** Results are recomputed only when the `parts`
* accessor returns a new array reference with a different length or last
* element. This is optimized for the typical AI SDK streaming pattern where
* parts are appended incrementally.
*
* @example
* ```tsx
* import { useJsonRenderMessage } from "@json-render/solid";
*
* function MessageBubble(props: { parts: DataPart[] }) {
* const msg = useJsonRenderMessage(() => props.parts);
*
* return (
* <div>
* {msg.text && <Markdown>{msg.text}</Markdown>}
* {msg.hasSpec && <MyRenderer spec={msg.spec!} />}
* </div>
* );
* }
* ```
*/
export function useJsonRenderMessage(getParts: () => DataPart[]) {
const result = createMemo(() => {
const parts = getParts();
return {
spec: buildSpecFromParts(parts),
text: getTextFromParts(parts),
};
});
return {
get spec() {
return result().spec;
},
get text() {
return result().text;
},
get hasSpec() {
const s = result().spec;
return s !== null && Object.keys(s.elements || {}).length > 0;
},
};
}
// =============================================================================
// useChatUI — Chat + GenUI hook
// =============================================================================
/**
* A single message in the chat, which may contain text, a rendered UI spec, or both.
*/
export interface ChatMessage {
/** Unique message ID */
id: string;
/** Who sent this message */
role: "user" | "assistant";
/** Text content (conversational prose) */
text: string;
/** json-render Spec built from JSONL patches (null if no UI was generated) */
spec: Spec | null;
}
/**
* Options for useChatUI
*/
export interface UseChatUIOptions {
/** API endpoint that accepts `{ messages: Array<{ role, content }> }` and returns a text stream */
api: string;
/** Callback when streaming completes for a message */
onComplete?: (message: ChatMessage) => void;
/** Callback on error */
onError?: (error: Error) => void;
}
/**
* Return type for useChatUI
*/
export interface UseChatUIReturn {
/** All messages in the conversation */
readonly messages: ChatMessage[];
/** Whether currently streaming an assistant response */
readonly isStreaming: boolean;
/** Error from the last request, if any */
readonly error: Error | null;
/** Send a user message */
send: (text: string) => Promise<void>;
/** Clear all messages and reset the conversation */
clear: () => void;
}
let chatMessageIdCounter = 0;
function generateChatId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
chatMessageIdCounter += 1;
return `msg-${Date.now()}-${chatMessageIdCounter}`;
}
/**
* Hook for chat + GenUI experiences.
*
* Manages a multi-turn conversation where each assistant message can contain
* both conversational text and a json-render UI spec. The hook sends the full
* message history to the API endpoint, reads the streamed response, and
* separates text lines from JSONL patch lines using `createMixedStreamParser`.
*
* @example
* ```tsx
* const chat = useChatUI({ api: "/api/chat" });
*
* // Send a message
* await chat.send("Compare weather in NYC and Tokyo");
*
* // Render messages
* <For each={chat.messages}>
* {(msg) => (
* <div>
* {msg.text && <p>{msg.text}</p>}
* {msg.spec && <MyRenderer spec={msg.spec} />}
* </div>
* )}
* </For>
* ```
*/
export function useChatUI(options: UseChatUIOptions): UseChatUIReturn {
const [messages, setMessages] = createSignal<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = createSignal(false);
const [error, setError] = createSignal<Error | null>(null);
let abortController: AbortController | null = null;
const clear = () => {
setMessages([]);
setError(null);
};
const send = async (text: string) => {
if (!text.trim()) return;
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
const userMessage: ChatMessage = {
id: generateChatId(),
role: "user",
text: text.trim(),
spec: null,
};
const assistantId = generateChatId();
const assistantMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: "",
spec: null,
};
// Append user message and empty assistant placeholder
setMessages((prev) => [...prev, userMessage, assistantMessage]);
setIsStreaming(true);
setError(null);
// Build messages array for the API (full conversation history + new message).
// Read current messages signal for latest state (avoids stale closure).
const historyForApi = [
...messages()
.filter((m) => m.id !== userMessage.id && m.id !== assistantId)
.map((m) => ({
role: m.role,
content: m.text,
})),
{ role: "user" as const, content: text.trim() },
];
// Mutable state for accumulating the assistant response
let accumulatedText = "";
let currentSpec: Spec = { root: "", elements: {} };
let hasSpec = false;
try {
const response = await fetch(options.api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: historyForApi }),
signal: abortController.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.error) {
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) {
throw new Error("No response body");
}
const decoder = new TextDecoder();
// Use createMixedStreamParser to classify lines
const parser = createMixedStreamParser({
onPatch(patch) {
hasSpec = true;
applySpecPatch(currentSpec, patch);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
spec: {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state
? { state: { ...currentSpec.state } }
: {}),
},
}
: m,
),
);
},
onText(line) {
accumulatedText += (accumulatedText ? "\n" : "") + line;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, text: accumulatedText } : m,
),
);
},
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.flush();
// Build final message for onComplete callback
const finalMessage: ChatMessage = {
id: assistantId,
role: "assistant",
text: accumulatedText,
spec: hasSpec
? {
root: currentSpec.root,
elements: { ...currentSpec.elements },
...(currentSpec.state ? { state: { ...currentSpec.state } } : {}),
}
: null,
};
options.onComplete?.(finalMessage);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
setError(resolvedError);
// Remove empty assistant message on error
setMessages((prev) =>
prev.filter((m) => m.id !== assistantId || m.text.length > 0),
);
options.onError?.(resolvedError);
} finally {
setIsStreaming(false);
}
};
// Cleanup on disposal
onCleanup(() => {
abortController?.abort();
});
return {
get messages() {
return messages();
},
get isStreaming() {
return isStreaming();
},
get error() {
return error();
},
send,
clear,
};
}
-109
View File
@@ -1,109 +0,0 @@
// Contexts
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
type StateContextValue,
type StateProviderProps,
} from "./contexts/state";
export {
VisibilityProvider,
useVisibility,
useIsVisible,
type VisibilityContextValue,
type VisibilityProviderProps,
} from "./contexts/visibility";
export {
ActionProvider,
useActions,
useAction,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./contexts/actions";
export {
ValidationProvider,
useValidation,
useOptionalValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./contexts/validation";
export {
RepeatScopeProvider,
useRepeatScope,
type RepeatScopeValue,
} from "./contexts/repeat-scope";
// Schema (Solid's spec format)
export {
schema,
type SolidSchema,
type SolidSpec,
// Backward compatibility
elementTreeSchema,
type ElementTreeSchema,
type ElementTreeSpec,
} from "./schema";
// Core types (re-exported for convenience)
export type { Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Solid
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "./catalog-types";
// Renderer
export {
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Low-level
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
// Hooks
export {
useUIStream,
useChatUI,
useBoundProp,
flatToTree,
buildSpecFromParts,
getTextFromParts,
useJsonRenderMessage,
type UseUIStreamOptions,
type UseUIStreamReturn,
type UseChatUIOptions,
type UseChatUIReturn,
type ChatMessage,
type DataPart,
type TokenUsage,
} from "./hooks";

Some files were not shown because too many files have changed in this diff Show More