Compare commits

..
Author SHA1 Message Date
Chris Tate dc38be8efe fixes 2026-02-25 00:59:21 -06:00
Chris Tate aa7eef1aea vue 2026-02-25 00:04:12 -06:00
72 changed files with 8138 additions and 5477 deletions
+2
View File
@@ -23,6 +23,8 @@ coverage
# Build Outputs
.next/
.nuxt/
.output/
out/
build
dist
+3 -27
View File
@@ -5,18 +5,15 @@
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash
# for React
npm install @json-render/core @json-render/react
# for React with pre-built shadcn/ui components
# pre-built shadcn/ui components
npm install @json-render/shadcn
# or for React Native
# or for mobile
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for Vue
npm install @json-render/core @json-render/vue
```
## Why json-render?
@@ -26,7 +23,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 (web), React Native (mobile) from the same catalog
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start
@@ -113,7 +110,6 @@ function Dashboard({ spec }) {
|---------|-------------|
| `@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/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 |
@@ -153,26 +149,6 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Vue (UI)
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
```
### shadcn/ui (Web)
```tsx
-246
View File
@@ -1,246 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/vue")
# @json-render/vue
Vue 3 components, providers, and composables.
## Providers
### StateProvider
```vue
<StateProvider :initial-state="object" :on-state-change="fn">
<!-- children -->
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue re-renders automatically:
store.set("/count", 1);
```
### ActionProvider
```vue
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
<!-- children -->
</ActionProvider>
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
```
### VisibilityProvider
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
### ValidationProvider
```vue
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
<!-- children -->
</ValidationProvider>
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
```
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
// Required when catalog declares actions:
actions: {
submit: async (params) => { /* ... */ },
},
});
// Pass to <Renderer>
// <Renderer :spec="spec" :registry="registry" />
```
## Components
### Renderer
```vue
<Renderer
:spec="Spec" // The UI spec to render
:registry="Registry" // Component registry (from defineRegistry)
:loading="boolean" // Optional loading state
:fallback="Component" // Optional fallback for unknown types
/>
```
### Component Props (via defineRegistry)
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean;
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Composables
### useStateStore
```typescript
const {
state, // ShallowRef<StateModel> — access with state.value
get, // (path: string) => unknown
set, // (path: string, value: unknown) => void
update, // (updates: Record<string, unknown>) => void
} = useStateStore();
```
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
### useStateValue
```typescript
const value = useStateValue(path: string); // ComputedRef<T | undefined>
```
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
### useStateBinding (deprecated)
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
```typescript
const [value, setValue] = useStateBinding(path: string);
// value: ComputedRef<T | undefined>
// setValue: (value: T) => void
```
### useActions
```typescript
const { execute } = useActions();
// execute(binding: ActionBinding) => Promise<void>
```
### useAction
```typescript
const { execute, isLoading } = useAction(binding: ActionBinding);
// execute: () => Promise<void>
// isLoading: ComputedRef<boolean>
```
### useIsVisible
```typescript
const isVisible = useIsVisible(condition?: VisibilityCondition);
```
### useFieldValidation
```typescript
const {
state, // ComputedRef<FieldValidationState>
validate, // () => ValidationResult
touch, // () => void
clear, // () => void
errors, // ComputedRef<string[]>
isValid, // ComputedRef<boolean>
} = useFieldValidation(path: string, config?: ValidationConfig);
```
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
## Differences from `@json-render/react`
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
+9 -12
View File
@@ -9,18 +9,6 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
<PackageInstall packages="react zod" />
## For Vue
<PackageInstall packages="@json-render/core @json-render/vue" />
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
<PackageInstall packages="vue zod" />
## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use:
@@ -49,6 +37,15 @@ If you want to wire json-render to an existing state management library instead
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
## Peer Dependencies
json-render requires the following peer dependencies:
- `react` ^19.0.0
- `zod` ^4.0.0
<PackageInstall packages="react zod" />
## For AI Integration
To use json-render with AI models, you'll also need the Vercel AI SDK:
-11
View File
@@ -69,16 +69,6 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
external: true,
},
{
title: "Renders with Vite (Vue / React)",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
external: true,
},
],
},
{
@@ -107,7 +97,6 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
-1
View File
@@ -38,7 +38,6 @@ export const PAGE_TITLES: Record<string, string> = {
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API",
+5
View File
@@ -0,0 +1,5 @@
<template>
<div id="app">
<NuxtPage />
</div>
</template>
+1
View File
@@ -0,0 +1 @@
@import "tailwindcss";
+399
View File
@@ -0,0 +1,399 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "Vue", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, and switches",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: { title: "Free", description: "$0/month" },
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary" },
},
pro: {
type: "Card",
props: { title: "Pro", description: "$19/month" },
children: ["proContent"],
},
proContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: { label: "Upgrade to Pro", variant: "primary" },
},
enterprise: {
type: "Card",
props: { title: "Enterprise", description: "Custom pricing" },
children: ["entContent"],
},
entContent: {
type: "Stack",
props: { direction: "vertical", gap: "sm" },
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: { label: "Contact Sales", variant: "secondary" },
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical", gap: "lg" },
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
];
+173
View File
@@ -0,0 +1,173 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string().optional(),
description: z.string().nullable().optional(),
maxWidth: z.enum(["sm", "md", "lg", "xl"]).nullable().optional(),
centered: z.boolean().nullable().optional(),
}),
slots: ["default"],
description: "A card container with optional title and description",
},
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).optional(),
gap: z.enum(["sm", "md", "lg"]).optional(),
align: z
.enum(["start", "center", "end", "stretch"])
.nullable()
.optional(),
justify: z
.enum(["start", "center", "end", "between", "around"])
.nullable()
.optional(),
}),
slots: ["default"],
description:
"Flex container for laying out children horizontally or vertically",
},
Grid: {
props: z.object({
columns: z.number().optional(),
gap: z.enum(["sm", "md", "lg"]).optional(),
}),
slots: ["default"],
description: "Grid layout container",
},
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).optional(),
}),
slots: [],
description: "Heading text element",
},
Text: {
props: z.object({
text: z.string(),
variant: z.enum(["body", "muted", "bold"]).optional(),
}),
slots: [],
description: "Text paragraph element",
},
Button: {
props: z.object({
label: z.string(),
variant: z
.enum(["primary", "secondary", "outline", "danger"])
.optional(),
disabled: z.boolean().nullable().optional(),
}),
slots: [],
description: "Clickable button. Emits 'press' event.",
},
Input: {
props: z.object({
label: z.string().optional(),
name: z.string(),
type: z.enum(["text", "email", "password", "number"]).optional(),
placeholder: z.string().optional(),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description:
"Text input field with optional label. Emits 'change' event.",
},
Select: {
props: z.object({
label: z.string().optional(),
name: z.string(),
options: z.array(z.string()),
placeholder: z.string().optional(),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description: "Dropdown select field. Emits 'change' event.",
},
Switch: {
props: z.object({
label: z.string(),
name: z.string(),
checked: z.boolean().optional(),
}),
slots: [],
description: "Toggle switch. Emits 'change' event.",
},
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "outline", "destructive"])
.optional(),
}),
slots: [],
description: "Small status badge label",
},
Separator: {
props: z.object({
orientation: z.enum(["horizontal", "vertical"]).nullable().optional(),
}),
slots: [],
description: "Visual separator line",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
}),
slots: [],
description: "Progress bar",
},
Alert: {
props: z.object({
title: z.string(),
message: z.string(),
type: z.enum(["info", "success", "warning", "error"]).optional(),
}),
slots: [],
description: "Alert / notification banner",
},
Table: {
props: z.object({
columns: z.array(z.string()),
rows: z.array(z.array(z.string())),
caption: z.string().optional(),
}),
slots: [],
description: "Data table with columns and rows",
},
Accordion: {
props: z.object({
items: z.array(z.object({ title: z.string(), content: z.string() })),
type: z.enum(["single", "multiple"]).optional(),
}),
slots: [],
description: "Collapsible accordion sections",
},
Radio: {
props: z.object({
label: z.string().optional(),
name: z.string(),
options: z.array(z.string()),
value: z.string().optional(),
checks: z.any().nullable().optional(),
validateOn: z.string().nullable().optional(),
}),
slots: [],
description: "Radio button group. Emits 'change' event.",
},
},
actions: {},
// Cast needed: zod schema types don't align with the inferred catalog input
// type from defineCatalog's generics when no actions are defined.
} as any);
+593
View File
@@ -0,0 +1,593 @@
import { h, ref, defineComponent, type VNode, type PropType } from "vue";
import {
defineRegistry,
useBoundProp,
useFieldValidation,
type BaseComponentProps,
} from "@json-render/vue";
import { catalog } from "./catalog";
type Ctx<P = Record<string, unknown>> = BaseComponentProps<P>;
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({
props,
children,
}: Ctx<{
title?: string;
description?: string | null;
maxWidth?: "sm" | "md" | "lg" | "xl" | null;
centered?: boolean | null;
}>) => {
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-sm"
: props.maxWidth === "md"
? "max-w-md"
: props.maxWidth === "lg"
? "max-w-lg"
: props.maxWidth === "xl"
? "max-w-xl"
: "";
return h(
"div",
{
class: [
"rounded-lg border bg-white shadow-sm p-6",
maxWidthClass,
props.centered ? "mx-auto" : "",
]
.filter(Boolean)
.join(" "),
},
[
props.title &&
h("h3", { class: "text-lg font-semibold" }, props.title),
props.description &&
h("p", { class: "text-sm text-gray-500 mt-1" }, props.description),
(props.title || props.description) &&
h("div", { class: "mt-4" }, children),
!(props.title || props.description) && children,
].filter(Boolean) as VNode[],
);
},
Stack: ({
props,
children,
}: Ctx<{
direction?: "horizontal" | "vertical";
gap?: "sm" | "md" | "lg";
align?: "start" | "center" | "end" | "stretch" | null;
justify?: "start" | "center" | "end" | "between" | "around" | null;
}>) => {
const gapClass =
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
const dirClass =
props.direction === "horizontal" ? "flex-row" : "flex-col";
const alignMap: Record<string, string> = {
start: "items-start",
center: "items-center",
end: "items-end",
stretch: "items-stretch",
};
const justifyMap: Record<string, string> = {
start: "justify-start",
center: "justify-center",
end: "justify-end",
between: "justify-between",
around: "justify-around",
};
const alignClass = props.align ? (alignMap[props.align] ?? "") : "";
const justifyClass = props.justify
? (justifyMap[props.justify] ?? "")
: "";
return h(
"div",
{
class: ["flex", dirClass, gapClass, alignClass, justifyClass]
.filter(Boolean)
.join(" "),
},
children,
);
},
Grid: ({
props,
children,
}: Ctx<{ columns?: number; gap?: "sm" | "md" | "lg" }>) => {
const cols = props.columns ?? 3;
const gapClass =
props.gap === "sm" ? "gap-2" : props.gap === "lg" ? "gap-6" : "gap-4";
const colClass =
cols === 1
? "grid-cols-1"
: cols === 2
? "grid-cols-2"
: cols === 3
? "grid-cols-3"
: cols === 4
? "grid-cols-4"
: `grid-cols-${cols}`;
return h("div", { class: `grid ${colClass} ${gapClass}` }, children);
},
Heading: ({
props,
}: Ctx<{ text: string; level?: "h1" | "h2" | "h3" | "h4" }>) => {
const tag = props.level ?? "h2";
const sizeClass =
tag === "h1"
? "text-3xl font-bold"
: tag === "h2"
? "text-2xl font-semibold"
: tag === "h3"
? "text-xl font-semibold"
: "text-lg font-medium";
return h(tag, { class: sizeClass }, props.text);
},
Text: ({
props,
}: Ctx<{ text: string; variant?: "body" | "muted" | "bold" }>) => {
const variantClass =
props.variant === "muted"
? "text-gray-500"
: props.variant === "bold"
? "font-semibold"
: "";
return h("p", { class: variantClass }, props.text);
},
Button: ({
props,
emit,
}: Ctx<{
label: string;
variant?: "primary" | "secondary" | "outline" | "danger";
disabled?: boolean | null;
}>) => {
const baseClass =
"inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
const variantClass =
props.variant === "primary"
? "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500"
: props.variant === "danger"
? "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500"
: props.variant === "outline"
? "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500";
return h(
"button",
{
class: `${baseClass} ${variantClass}`,
disabled: props.disabled ?? false,
onClick: () => emit("press"),
},
props.label,
);
},
Input: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
type?: string;
placeholder?: string;
value?: string;
checks?: unknown;
validateOn?: string | null;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
const validation = props.checks
? useFieldValidation(`/${props.name}`, {
checks: props.checks as any[],
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
})
: null;
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h("input", {
type: props.type ?? "text",
placeholder: props.placeholder,
value: value ?? "",
class:
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
onInput: (e: Event) => {
const val = (e.target as HTMLInputElement).value;
setValue(val);
emit("change");
if (validation && props.validateOn === "change") {
validation.validate();
}
},
onBlur: () => {
if (validation && props.validateOn === "blur") {
validation.touch();
validation.validate();
}
},
}),
validation &&
validation.errors.value.length > 0 &&
h(
"p",
{ class: "text-xs text-red-500" },
validation.errors.value[0],
),
].filter(Boolean) as VNode[],
);
},
Select: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
options: string[];
placeholder?: string;
value?: string;
checks?: unknown;
validateOn?: string | null;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
const options = props.options ?? [];
const validation = props.checks
? useFieldValidation(`/${props.name}`, {
checks: props.checks as any[],
validateOn: (props.validateOn as "blur" | "change") ?? undefined,
})
: null;
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h(
"select",
{
value: value ?? "",
class:
"rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500",
onChange: (e: Event) => {
const val = (e.target as HTMLSelectElement).value;
setValue(val);
emit("change");
if (validation && props.validateOn === "change") {
validation.validate();
}
},
},
[
props.placeholder &&
h("option", { value: "", disabled: true }, props.placeholder),
...options.map((opt: string) =>
h("option", { value: opt, key: opt }, opt),
),
].filter(Boolean) as VNode[],
),
validation &&
validation.errors.value.length > 0 &&
h(
"p",
{ class: "text-xs text-red-500" },
validation.errors.value[0],
),
].filter(Boolean) as VNode[],
);
},
Switch: ({
props,
bindings,
emit,
}: Ctx<{ label: string; name: string; checked?: boolean }>) => {
const [checked, setChecked] = useBoundProp<boolean>(
props.checked,
bindings?.checked,
);
const isOn = checked ?? false;
return h("label", { class: "flex items-center gap-3 cursor-pointer" }, [
h(
"button",
{
role: "switch",
"aria-checked": isOn,
class: [
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isOn ? "bg-blue-600" : "bg-gray-200",
].join(" "),
onClick: () => {
setChecked(!isOn);
emit("change");
},
},
h("span", {
class: [
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
isOn ? "translate-x-6" : "translate-x-1",
].join(" "),
}),
),
h("span", { class: "text-sm" }, props.label),
]);
},
Badge: ({
props,
}: Ctx<{
text: string;
variant?: "default" | "secondary" | "outline" | "destructive";
}>) => {
const variantClass =
props.variant === "secondary"
? "bg-gray-100 text-gray-800"
: props.variant === "outline"
? "border border-gray-300 text-gray-700"
: props.variant === "destructive"
? "bg-red-100 text-red-800"
: "bg-blue-100 text-blue-800";
return h(
"span",
{
class: `inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${variantClass}`,
},
props.text,
);
},
Separator: () => {
return h("hr", { class: "border-gray-200" });
},
Progress: ({
props,
}: Ctx<{ value: number; max?: number; label?: string }>) => {
const pct = Math.min(
100,
Math.round((props.value / (props.max ?? 100)) * 100),
);
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h("div", { class: "flex justify-between text-sm" }, [
h("span", { class: "text-gray-600" }, props.label),
h("span", { class: "font-medium" }, `${pct}%`),
]),
h(
"div",
{
class: "h-2 w-full rounded-full bg-gray-200 overflow-hidden",
},
h("div", {
class: "h-full rounded-full bg-blue-600 transition-all",
style: { width: `${pct}%` },
}),
),
].filter(Boolean) as VNode[],
);
},
Alert: ({
props,
}: Ctx<{
title: string;
message: string;
type?: "info" | "success" | "warning" | "error";
}>) => {
const typeClasses: Record<string, string> = {
success: "bg-green-50 border-green-200 text-green-800",
warning: "bg-yellow-50 border-yellow-200 text-yellow-800",
error: "bg-red-50 border-red-200 text-red-800",
info: "bg-blue-50 border-blue-200 text-blue-800",
};
const cls = typeClasses[props.type ?? "info"] ?? typeClasses.info;
return h("div", { class: `rounded-md border p-4 ${cls}` }, [
h("p", { class: "text-sm font-medium" }, props.title),
h("p", { class: "text-sm mt-1 opacity-80" }, props.message),
]);
},
Table: ({
props,
}: Ctx<{
columns: string[];
rows: string[][];
caption?: string;
}>) => {
return h(
"div",
{ class: "overflow-x-auto" },
[
h("table", { class: "min-w-full text-sm" }, [
h(
"thead",
null,
h(
"tr",
{ class: "border-b" },
props.columns.map((col: string) =>
h(
"th",
{
key: col,
class: "px-4 py-2 text-left font-medium text-gray-500",
},
col,
),
),
),
),
h(
"tbody",
null,
props.rows.map((row: string[], i: number) =>
h(
"tr",
{ key: i, class: "border-b last:border-0" },
row.map((cell: string, j: number) =>
h("td", { key: j, class: "px-4 py-2 text-gray-700" }, cell),
),
),
),
),
]),
props.caption &&
h("p", { class: "text-xs text-gray-400 mt-2 px-4" }, props.caption),
].filter(Boolean) as VNode[],
);
},
// Uses defineComponent instead of a plain render function because it
// needs local reactive state (openItems ref) to track expanded items.
Accordion: defineComponent({
props: {
props: {
type: Object as PropType<{
items: Array<{ title: string; content: string }>;
type?: "single" | "multiple";
}>,
required: true,
},
},
setup(compProps) {
const openItems = ref<Set<number>>(new Set());
const toggle = (idx: number) => {
const next = new Set(openItems.value);
if (next.has(idx)) {
next.delete(idx);
} else {
if (compProps.props.type === "single") next.clear();
next.add(idx);
}
openItems.value = next;
};
return () =>
h(
"div",
{ class: "divide-y border rounded-md" },
compProps.props.items.map(
(item: { title: string; content: string }, i: number) =>
h("div", { key: i }, [
h(
"button",
{
class:
"flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-left hover:bg-gray-50 transition-colors",
onClick: () => toggle(i),
},
[
h("span", null, item.title),
h(
"span",
{
class: [
"transform transition-transform text-gray-400",
openItems.value.has(i) ? "rotate-180" : "",
].join(" "),
},
"\u25BE",
),
],
),
openItems.value.has(i) &&
h(
"div",
{ class: "px-4 pb-3 text-sm text-gray-600" },
item.content,
),
]),
),
);
},
}) as any,
Radio: ({
props,
bindings,
emit,
}: Ctx<{
label?: string;
name: string;
options: string[];
value?: string;
}>) => {
const [value, setValue] = useBoundProp<string>(
props.value,
bindings?.value,
);
return h(
"div",
{ class: "flex flex-col gap-1.5" },
[
props.label &&
h(
"label",
{ class: "text-sm font-medium text-gray-700" },
props.label,
),
h(
"div",
{ class: "flex gap-4" },
(props.options ?? []).map((opt: string) =>
h(
"label",
{
key: opt,
class: "flex items-center gap-2 cursor-pointer",
},
[
h("input", {
type: "radio",
name: props.name,
value: opt,
checked: value === opt,
class: "accent-blue-600",
onChange: () => {
setValue(opt);
emit("change");
},
}),
h("span", { class: "text-sm" }, opt),
],
),
),
),
].filter(Boolean) as VNode[],
);
},
},
});
+10
View File
@@ -0,0 +1,10 @@
import tailwindcss from "@tailwindcss/vite";
export default defineNuxtConfig({
compatibilityDate: "2025-11-01",
css: ["~/assets/css/main.css"],
vite: {
// Tailwind's Vite plugin types don't align with Nuxt's plugin types
plugins: [tailwindcss() as any],
},
});
+25
View File
@@ -0,0 +1,25 @@
{
"name": "example-nuxt",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "portless nuxt-demo.json-render nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview",
"check-types": "nuxt typecheck"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"nuxt": "^4.3.1",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.1",
"tailwindcss": "^4.2.1",
"typescript": "^5.7.2"
}
}
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import { JSONUIProvider, Renderer } from "@json-render/vue";
import { registry } from "~/lib/render/registry";
import { examples } from "~/lib/examples";
const selectedIndex = ref(0);
const selected = computed(() => examples[selectedIndex.value]!);
</script>
<template>
<div class="min-h-screen flex flex-col bg-gray-50">
<!-- Example selector -->
<nav class="flex gap-1 p-3 overflow-x-auto border-b bg-white shrink-0">
<button
v-for="(ex, i) in examples"
:key="ex.name"
class="px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors"
:class="
i === selectedIndex
? 'bg-blue-600 text-white'
: 'hover:bg-gray-100 text-gray-700'
"
@click="selectedIndex = i"
>
{{ ex.name }}
</button>
</nav>
<!-- Render area -->
<div class="flex-1 flex items-start justify-center overflow-auto p-6">
<div
class="bg-white border rounded-lg shadow-sm w-full max-w-[960px] p-6"
>
<p class="text-xs text-gray-400 mb-4">
{{ selected.description }}
</p>
<JSONUIProvider
:key="selectedIndex"
:registry="registry"
:initial-state="selected.spec.state ?? {}"
>
<Renderer :spec="selected.spec" :registry="registry" />
</JSONUIProvider>
</div>
</div>
</div>
</template>
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}
-31
View File
@@ -1,31 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@json-render multi-renderer demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
#renderer-root {
max-width: 640px;
margin: 0 auto;
padding: 24px;
}
</style>
</head>
<body>
<div id="renderer-root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-27
View File
@@ -1,27 +0,0 @@
{
"name": "vite-renderers",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "portless vite-renderers.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
-30
View File
@@ -1,30 +0,0 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react";
const container = document.getElementById("renderer-root") as HTMLElement;
let unmountCurrent: (() => void) | null = null;
async function switchTo(renderer: Renderer) {
unmountCurrent?.();
container.innerHTML = "";
if (renderer === "vue") {
const mod = await import("./vue/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
// The RendererTabs component (rendered by JSON renderer) dispatches this event
document.addEventListener("switch-renderer", (e: Event) => {
switchTo((e as CustomEvent<string>).detail as Renderer);
});
// Default: Vue
switchTo("vue");
-51
View File
@@ -1,51 +0,0 @@
import { useMemo } from "react";
import type { Spec } from "@json-render/core";
import {
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/react";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
function DemoRenderer({ spec }: { spec: Spec }) {
const { get, set } = useStateStore();
const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
export default function App({
initialRenderer = "vue",
spec,
}: {
initialRenderer?: string;
spec: Spec;
}) {
return (
<div className={`renderer-${initialRenderer}`}>
<StateProvider
initialState={{ ...spec.state, renderer: initialRenderer }}
>
<DemoRenderer spec={spec} />
</StateProvider>
</div>
);
}
@@ -1,5 +0,0 @@
import { schema } from "@json-render/react/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -1,15 +0,0 @@
import { createRoot, type Root } from "react-dom/client";
import type { Spec } from "@json-render/core";
import App from "./App";
let root: Root | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
root = createRoot(container);
root.render(<App initialRenderer={renderer} spec={spec} />);
}
export function unmount() {
root?.unmount();
root = null;
}
@@ -1,155 +0,0 @@
import type { Components } from "@json-render/react";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => (
<div
className={[
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
}}
>
{children}
</div>
),
Card: ({ props, children }) => (
<div className="json-render-card">
{props.title && (
<div className="json-render-card-title-wrap">
<h2 className="json-render-card-title">{props.title}</h2>
</div>
)}
{props.subtitle && (
<p className="json-render-card-subtitle">{props.subtitle}</p>
)}
{children}
</div>
),
Text: ({ props }) => (
<span
className={[
"json-render-text",
props.size && props.size !== "md" && `json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={props.color ? { color: props.color } : undefined}
>
{String(props.content ?? "")}
</span>
),
Button: ({ props, emit }) => (
<button
disabled={props.disabled}
onClick={() => emit("press")}
className={[
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{props.label}
</button>
),
Badge: ({ props }) => (
<span
className="json-render-badge"
style={
props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined
}
>
{props.label}
</span>
),
ListItem: ({ props, emit }) => (
<div
onClick={() => emit("press")}
className={[
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
className={[
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{props.completed ? "✓" : ""}
</div>
<span
className={[
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{props.title}
</span>
</div>
),
RendererBadge: ({ props }) => (
<span className="json-render-renderer-badge">
<span className="json-render-renderer-dot" />
{props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
</span>
),
RendererTabs: ({ props, emit }) => (
<div className="json-render-renderer-tabs-wrapper">
<span className="json-render-renderer-tabs-label">Render</span>
<div className="json-render-renderer-tabs">
<button
onClick={() => emit("pressVue")}
className={[
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => emit("pressReact")}
className={[
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
</div>
</div>
),
};
@@ -1,99 +0,0 @@
import { z } from "zod";
/**
* Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
* Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
*/
export const catalogDef = {
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 and React 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",
},
},
};
@@ -1,50 +0,0 @@
type Get = (path: string) => unknown;
type Set = (path: string, value: unknown) => void;
/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
export const actionStubs = {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
export function makeHandlers(get: Get, set: Set) {
return {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) todos[index] = { ...item, completed: !item.completed };
set("/todos", todos);
},
switchToVue: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "vue" }),
);
},
switchToReact: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "react" }),
);
},
};
}
@@ -1,248 +0,0 @@
/* ---- Stack ---------------------------------------------------------------- */
.json-render-stack {
display: flex;
flex-direction: column;
align-items: stretch;
}
.json-render-stack--horizontal {
flex-direction: row;
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; }
/* ---- Card ----------------------------------------------------------------- */
.json-render-card {
background-color: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.json-render-card-title-wrap {
margin-bottom: 4px;
}
.json-render-card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.json-render-card-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px 0;
}
/* ---- Text ----------------------------------------------------------------- */
.json-render-text {
font-size: 14px;
font-weight: 400;
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--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
/* ---- Button --------------------------------------------------------------- */
.json-render-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: background 0.15s;
background-color: #3b82f6;
color: white;
}
.json-render-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.json-render-button--primary {
background-color: #3b82f6;
color: white;
}
.json-render-button--secondary {
background-color: #f3f4f6;
color: #374151;
}
.json-render-button--danger {
background-color: #fee2e2;
color: #dc2626;
}
/* ---- Badge ---------------------------------------------------------------- */
.json-render-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
/* ---- ListItem ------------------------------------------------------------- */
.json-render-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
}
.json-render-list-item--done {
background-color: #f0fdf4;
border-color: #bbf7d0;
}
.json-render-list-item-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #d1d5db;
background-color: transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
}
.json-render-list-item-check--done {
border-color: #16a34a;
background-color: #16a34a;
}
.json-render-list-item-text {
font-size: 14px;
color: #111827;
text-decoration: none;
}
.json-render-list-item-text--done {
color: #6b7280;
text-decoration: line-through;
}
/* ---- RendererBadge -------------------------------------------------------- */
.json-render-renderer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 10px 15px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
/* default colors; overridden by renderer parent class below */
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.json-render-renderer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
/* background overridden by renderer parent class below */
background-color: #0369a1;
}
/* ---- RendererTabs --------------------------------------------------------- */
.json-render-renderer-tabs-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.json-render-renderer-tabs-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.json-render-renderer-tabs {
display: inline-flex;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.json-render-renderer-tab {
padding: 6px 16px;
border: none;
border-right: 0;
cursor: pointer;
font-size: 13px;
font-weight: 500;
background-color: white;
color: #374151;
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
border-right: 1px solid #e5e7eb;
}
/* ---- Renderer-specific overrides ----------------------------------------- */
.renderer-vue .json-render-renderer-badge {
color: #42b883;
background-color: #42b88318;
border-color: #42b88340;
}
.renderer-vue .json-render-renderer-dot {
background-color: #42b883;
}
.renderer-vue .json-render-renderer-tab--active {
background-color: #42b883;
color: white;
}
.renderer-react .json-render-renderer-badge {
color: #149eca;
background-color: #149eca18;
border-color: #149eca40;
}
.renderer-react .json-render-renderer-dot {
background-color: #149eca;
}
.renderer-react .json-render-renderer-tab--active {
background-color: #149eca;
color: white;
}
-126
View File
@@ -1,126 +0,0 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
renderer: "vue",
count: 0,
todos: [
{ id: 1, title: "Learn JSON Render", completed: true },
{
id: 2,
title: "Try @json-render/vue or @json-render/react",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"demo-title",
"renderer-tabs",
"renderer-badge",
"counter-card",
"milestone-badge",
"todos-card",
],
},
"demo-title": {
type: "Text",
props: {
content: "@json-render multi-renderer demo",
size: "xl",
weight: "bold",
},
},
"renderer-badge": {
type: "RendererBadge",
props: { renderer: { $state: "/renderer" } },
},
"renderer-tabs": {
type: "RendererTabs",
props: { renderer: { $state: "/renderer" } },
on: {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
},
};
-19
View File
@@ -1,19 +0,0 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/vue";
import DemoRenderer from "./DemoRenderer.vue";
const props = defineProps<{
initialRenderer?: string; spec: Spec }>();
const initialState = {
...props.spec.state,
renderer: props.initialRenderer ?? "vue" };
</script>
<template>
<div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
<StateProvider :initial-state="initialState">
<DemoRenderer :spec="props.spec" />
</StateProvider>
</div>
</template>
@@ -1,25 +0,0 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import {
ActionProvider, ValidationProvider, VisibilityProvider,
Renderer, defineRegistry, useStateStore,
} from "@json-render/vue";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const props = defineProps<{ spec: Spec }>();
const { get, set } = useStateStore();
const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
const handlers = makeHandlers(get, set);
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="props.spec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
@@ -1,5 +0,0 @@
import { schema } from "@json-render/vue/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
-15
View File
@@ -1,15 +0,0 @@
import { createApp, type App } from "vue";
import type { Spec } from "@json-render/core";
import VueApp from "./App.vue";
let app: App | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
app = createApp(VueApp, { initialRenderer: renderer, spec });
app.mount(container);
}
export function unmount() {
app?.unmount();
app = null;
}
-166
View File
@@ -1,166 +0,0 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) =>
h(
"div",
{
class: [
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" "),
style: {
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
},
},
children,
),
Card: ({ props, children }) =>
h("div", { class: "json-render-card" }, [
props.title &&
h("div", { class: "json-render-card-title-wrap" }, [
h("h2", { class: "json-render-card-title" }, props.title),
]),
props.subtitle &&
h("p", { class: "json-render-card-subtitle" }, props.subtitle),
children,
]),
Text: ({ props }) =>
h(
"span",
{
class: [
"json-render-text",
props.size &&
props.size !== "md" &&
`json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" "),
style: props.color ? { color: props.color } : undefined,
},
String(props.content ?? ""),
),
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
class: [
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" "),
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
class: "json-render-badge",
style: props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined,
},
props.label,
),
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
class: [
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" "),
},
[
h(
"div",
{
class: [
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" "),
},
props.completed ? "✓" : "",
),
h(
"span",
{
class: [
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" "),
},
props.title,
),
],
),
RendererBadge: ({ props }) =>
h("span", { class: "json-render-renderer-badge" }, [
h("span", { class: "json-render-renderer-dot" }),
props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
]),
RendererTabs: ({ props, emit }) =>
h("div", { class: "json-render-renderer-tabs-wrapper" }, [
h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
h("div", { class: "json-render-renderer-tabs" }, [
h(
"button",
{
onClick: () => emit("pressVue"),
class: [
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Vue",
),
h(
"button",
{
onClick: () => emit("pressReact"),
class: [
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"React",
),
]),
]),
};
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
-7
View File
@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [vue(), react({ include: /\.tsx$/ })],
});
-26
View File
@@ -1,26 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>json-render vue example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f9fafb;
color: #111827;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-22
View File
@@ -1,22 +0,0 @@
{
"name": "vue",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "portless vue.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"vue": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.2.5"
}
}
-14
View File
@@ -1,14 +0,0 @@
<script setup lang="ts">
import { StateProvider } from "@json-render/vue";
import { demoSpec } from "./lib/spec";
import DemoRenderer from "./DemoRenderer.vue";
const initialState = demoSpec.state ?? {};
</script>
<template>
<!-- StateProvider sets up the reactive state store for the whole tree -->
<StateProvider :initial-state="initialState">
<DemoRenderer />
</StateProvider>
</template>
-68
View File
@@ -1,68 +0,0 @@
<script setup lang="ts">
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/vue";
import { catalog } from "./lib/catalog";
import { components } from "./lib/registry";
import { demoSpec } from "./lib/spec";
// Access the state store provided by the parent StateProvider
const { get, set } = useStateStore();
// Build registry — include stub actions to satisfy catalog types.
// The actual logic runs in the handlers below, which have direct
// access to the state store.
const { registry } = defineRegistry(catalog, {
components,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
},
});
// Action handlers — close over the state store's get/set so they
// can read and write state directly without needing an external store.
const handlers = {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) {
todos[index] = { ...item, completed: !item.completed };
}
set("/todos", todos);
},
};
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="demoSpec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
-92
View File
@@ -1,92 +0,0 @@
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
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",
},
Input: {
props: z.object({
value: z.string().optional(),
placeholder: z.string().optional(),
}),
slots: [],
description: "A text input field that supports two-way state binding",
},
},
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",
},
},
});
export type AppCatalog = typeof catalog;
-213
View File
@@ -1,213 +0,0 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import { useBoundProp } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => {
const isHorizontal = props.direction === "horizontal";
return h(
"div",
{
style: {
display: "flex",
flexDirection: isHorizontal ? "row" : "column",
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
},
},
children,
);
},
Card: ({ props, children }) =>
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "20px",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
},
},
[
props.title &&
h("div", { style: { marginBottom: "4px" } }, [
h(
"h2",
{
style: {
fontSize: "16px",
fontWeight: "600",
color: "#111827",
margin: 0,
},
},
props.title,
),
]),
props.subtitle &&
h(
"p",
{
style: {
fontSize: "13px",
color: "#6b7280",
margin: "0 0 12px 0",
},
},
props.subtitle,
),
children,
],
),
Text: ({ props }) => {
const sizeMap: Record<string, string> = {
sm: "12px",
md: "14px",
lg: "16px",
xl: "24px",
};
const weightMap: Record<string, string> = {
normal: "400",
medium: "500",
bold: "700",
};
return h(
"span",
{
style: {
fontSize: sizeMap[props.size ?? "md"] ?? "14px",
fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
color: props.color ?? "#111827",
},
},
String(props.content ?? ""),
);
},
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
style: {
padding: "8px 16px",
borderRadius: "8px",
border: "none",
cursor: props.disabled ? "not-allowed" : "pointer",
fontWeight: "500",
fontSize: "14px",
transition: "background 0.15s",
opacity: props.disabled ? "0.5" : "1",
backgroundColor:
props.variant === "danger"
? "#fee2e2"
: props.variant === "secondary"
? "#f3f4f6"
: "#3b82f6",
color:
props.variant === "danger"
? "#dc2626"
: props.variant === "secondary"
? "#374151"
: "white",
},
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
style: {
display: "inline-block",
padding: "4px 12px",
borderRadius: "999px",
fontSize: "13px",
fontWeight: "500",
backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
color: props.color ?? "#0369a1",
border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
},
},
props.label,
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return h("input", {
value: value ?? "",
placeholder: props.placeholder as string | undefined,
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
style: {
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid #d1d5db",
fontSize: "14px",
outline: "none",
width: "100%",
boxSizing: "border-box",
},
});
},
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
style: {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 12px",
borderRadius: "8px",
cursor: "pointer",
backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
},
},
[
h(
"div",
{
style: {
width: "18px",
height: "18px",
borderRadius: "50%",
border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
backgroundColor: props.completed ? "#16a34a" : "transparent",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "11px",
color: "white",
},
},
props.completed ? "✓" : "",
),
h(
"span",
{
style: {
fontSize: "14px",
color: props.completed ? "#6b7280" : "#111827",
textDecoration: props.completed ? "line-through" : "none",
},
},
props.title,
),
],
),
};
-138
View File
@@ -1,138 +0,0 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
count: 0,
name: "",
todos: [
{ id: 1, title: "Learn Vue 3", completed: true },
{ id: 2, title: "Try @json-render/vue", completed: false },
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"header",
"counter-card",
"milestone-badge",
"todos-card",
"input-card",
],
},
header: {
type: "Text",
props: {
content: "@json-render/vue demo",
size: "xl",
weight: "bold",
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
// ---- Bound Input card (useBoundProp demo) ----
"input-card": {
type: "Card",
props: {
title: "Bound Input",
subtitle: "Type to update state — the display text reacts in real time",
},
children: ["input-body"],
},
"input-body": {
type: "Stack",
props: { gap: 12, direction: "vertical" },
children: ["name-input", "name-display"],
},
"name-input": {
type: "Input",
props: {
value: { $bindState: "/name" },
placeholder: "Enter your name…",
},
},
"name-display": {
type: "Text",
props: {
content: { $state: "/name" },
size: "md",
color: "#6b7280",
},
},
},
};
-4
View File
@@ -1,4 +0,0 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
-17
View File
@@ -1,17 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
-6
View File
@@ -1,6 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+2
View File
@@ -30,6 +30,7 @@
"@changesets/cli": "2.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"@testing-library/vue": "^8.1.0",
"@types/react": "^19.2.3",
"husky": "^9.1.7",
"jsdom": "^27.4.0",
@@ -39,6 +40,7 @@
"react-dom": "^19.2.4",
"turbo": "^2.7.4",
"typescript": "5.9.2",
"vue": "^3.5.29",
"vitest": "^4.0.17"
},
"packageManager": "pnpm@10.29.3",
+1 -7
View File
@@ -251,15 +251,9 @@ export function ActionProvider({
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 });
set(statePath, { valid });
return;
}
+19 -31
View File
@@ -3,7 +3,6 @@
import React, {
createContext,
useContext,
useRef,
useState,
useCallback,
useMemo,
@@ -135,9 +134,6 @@ export function ValidationProvider({
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
// Mutable mirror of fieldStates for synchronous reads (e.g. reading errors
// immediately after validateAll() before React flushes the batched setState).
const fieldStatesRef = useRef<Record<string, FieldValidationState>>({});
const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig>
>({});
@@ -179,16 +175,14 @@ export function ValidationProvider({
customFunctions,
});
const newFieldState: FieldValidationState = {
touched: fieldStatesRef.current[path]?.touched ?? true,
validated: true,
result,
};
fieldStatesRef.current = {
...fieldStatesRef.current,
[path]: newFieldState,
};
setFieldStates(fieldStatesRef.current);
setFieldStates((prev) => ({
...prev,
[path]: {
touched: prev[path]?.touched ?? true,
validated: true,
result,
},
}));
return result;
},
@@ -196,22 +190,22 @@ export function ValidationProvider({
);
const touch = useCallback((path: string) => {
fieldStatesRef.current = {
...fieldStatesRef.current,
setFieldStates((prev) => ({
...prev,
[path]: {
...fieldStatesRef.current[path],
...prev[path],
touched: true,
validated: fieldStatesRef.current[path]?.validated ?? false,
result: fieldStatesRef.current[path]?.result ?? null,
validated: prev[path]?.validated ?? false,
result: prev[path]?.result ?? null,
},
};
setFieldStates(fieldStatesRef.current);
}));
}, []);
const clear = useCallback((path: string) => {
const { [path]: _, ...rest } = fieldStatesRef.current;
fieldStatesRef.current = rest;
setFieldStates(rest);
setFieldStates((prev) => {
const { [path]: _, ...rest } = prev;
return rest;
});
}, []);
const validateAll = useCallback(() => {
@@ -230,11 +224,7 @@ export function ValidationProvider({
const value = useMemo<ValidationContextValue>(
() => ({
customFunctions,
// Getter returns the mutable ref so callers that read fieldStates
// synchronously after validateAll() see the latest values.
get fieldStates() {
return fieldStatesRef.current;
},
fieldStates,
validate,
touch,
clear,
@@ -243,8 +233,6 @@ export function ValidationProvider({
}),
[
customFunctions,
// fieldStates (React state) stays in deps so the context value object
// is recreated on re-render, triggering downstream consumers.
fieldStates,
validate,
touch,
+3 -6
View File
@@ -546,10 +546,7 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
expect(state.result).toEqual({ valid: false });
});
it("writes { valid: true } when all fields pass validation", async () => {
@@ -604,7 +601,7 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.result).toEqual({ valid: true, errors: {} });
expect(state.result).toEqual({ valid: true });
});
it("defaults to /formValidation when no statePath is provided", async () => {
@@ -654,7 +651,7 @@ describe("validateForm action", () => {
});
const state = getState();
expect(state.formValidation).toEqual({ valid: true, errors: {} });
expect(state.formValidation).toEqual({ valid: true });
});
});
+1 -1
View File
@@ -70,7 +70,7 @@ export const schema = defineSchema(
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
},
],
defaultRules: [
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "vue"
}
}
+145 -366
View File
@@ -8,8 +8,6 @@ Vue 3 renderer for json-render. Turn JSON specs into Vue components with data bi
npm install @json-render/vue @json-render/core zod
```
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
## Quick Start
### 1. Create a Catalog
@@ -31,7 +29,6 @@ export const catalog = defineCatalog(schema, {
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
description: "A clickable button",
},
@@ -46,20 +43,15 @@ export const catalog = defineCatalog(schema, {
},
actions: {
submit: { description: "Submit the form" },
cancel: { description: "Cancel and close" },
},
});
```
### 2. Define Component Implementations
Components are written using Vue's `h()` render function. `children` is a `VNode | VNode[]` — pass it directly to your container element.
`defineRegistry` conditionally requires the `actions` field only when the catalog declares actions. Catalogs with `actions: {}` can omit it entirely.
```typescript
import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
import { defineRegistry, useBoundProp } from "@json-render/vue";
import { catalog } from "./catalog";
export const { registry } = defineRegistry(catalog, {
@@ -67,430 +59,217 @@ export const { registry } = defineRegistry(catalog, {
Card: ({ props, children }) =>
h("div", { class: "card" }, [
h("h3", null, props.title),
props.description ? h("p", null, props.description) : null,
props.description && h("p", null, props.description),
children,
]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
Input: ({ props, bindings }) => {
// Use bindings?.value with a watcher to implement two-way binding
const [value, setValue] = useBoundProp(props.value, bindings?.value);
return h("label", null, [
props.label,
h("input", {
placeholder: props.placeholder ?? "",
value: props.value ?? "",
value: value ?? "",
onInput: (e: Event) =>
setValue((e.target as HTMLInputElement).value),
}),
]);
},
},
// actions stubs are required when the catalog declares actions:
actions: {
submit: async () => {},
cancel: async () => {},
submit: async (_params, _setState) => {
console.log("Form submitted");
},
},
});
```
> **Tip:** Use `useBoundProp(props.value, bindings?.value)` for two-way binding, or handle the `bindings` object directly in your component.
### 3. Render Specs
### 3. Render a Spec
```vue
<script setup lang="ts">
import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
import { JSONUIProvider, Renderer } from "@json-render/vue";
import { registry } from "./registry";
const spec = { /* ... */ };
function handleSubmit(params) {
console.log("Submit", params);
}
const spec = {
root: "card",
state: { name: "" },
elements: {
card: {
type: "Card",
props: { title: "Hello", description: null },
children: ["input", "btn"],
},
input: {
type: "Input",
props: {
label: "Name",
placeholder: "Enter your name",
value: { $bindState: "/name" },
},
},
btn: {
type: "Button",
props: { label: "Submit" },
on: { press: { action: "submit" } },
},
},
};
</script>
<template>
<StateProvider :initial-state="{ form: { name: '' } }">
<ActionProvider :handlers="{ submit: handleSubmit }">
<Renderer :spec="spec" :registry="registry" />
</ActionProvider>
</StateProvider>
<JSONUIProvider :registry="registry" :initial-state="spec.state ?? {}">
<Renderer :spec="spec" :registry="registry" />
</JSONUIProvider>
</template>
```
## Spec Format
The Vue renderer uses the same flat element map format as the React renderer:
```typescript
interface Spec {
root: string; // Key of the root element
elements: Record<string, UIElement>; // Flat map of elements by key
state?: Record<string, unknown>; // Optional initial state
}
interface UIElement {
type: string; // Component name from catalog
props: Record<string, unknown>; // Component props
children?: string[]; // Keys of child elements
visible?: VisibilityCondition; // Visibility condition
}
```
Example spec:
The spec is a flat map of elements. Each element references its children by key:
```json
{
"root": "card-1",
"root": "card",
"state": { "count": 0 },
"elements": {
"card-1": {
"card": {
"type": "Card",
"props": { "title": "Welcome" },
"children": ["input-1", "btn-1"]
"props": { "title": "Counter" },
"children": ["display", "increment"]
},
"input-1": {
"type": "Input",
"props": {
"value": { "$bindState": "/form/name" },
"label": "Name",
"placeholder": "Enter name"
}
"display": {
"type": "Text",
"props": { "text": { "$state": "/count" } }
},
"btn-1": {
"increment": {
"type": "Button",
"props": { "label": "Submit" },
"children": []
"props": { "label": "+1" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/count", "value": 1 }
}
}
}
}
}
```
## Providers
### StateProvider
Share data across components with JSON Pointer paths:
```vue
<StateProvider :initial-state="{ user: { name: 'John' } }">
<!-- children -->
</StateProvider>
```
```typescript
// In composables:
const { state, get, set } = useStateStore();
const name = get("/user/name"); // "John"
set("/user/age", 25);
// state is a ShallowRef<StateModel> — access with state.value
console.log(state.value);
```
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):
```typescript
import { createStateStore, type StateStore } from "@json-render/vue";
// Option 1: Use the built-in store outside of Vue
const store = createStateStore({ count: 0 });
```
```vue
<StateProvider :store="store">
<!-- children -->
</StateProvider>
```
```typescript
// Mutate from anywhere — Vue will re-render automatically:
store.set("/count", 1);
// Option 2: Implement the StateStore interface with your own backend
const piniaStore: StateStore = {
get: (path) => getByPath(myStore.$state, path),
set: (path, value) => myStore.$patch(/* ... */),
update: (updates) => myStore.$patch(/* ... */),
getSnapshot: () => myStore.$state,
subscribe: (listener) => myStore.$subscribe(listener),
};
```
When `store` is provided, `initialState` and `onStateChange` are ignored.
### ActionProvider
Handle actions from components:
```vue
<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
<!-- children -->
</ActionProvider>
```
### VisibilityProvider
Control element visibility based on data:
```vue
<VisibilityProvider>
<!-- children -->
</VisibilityProvider>
```
```json
{
"type": "Alert",
"props": { "message": "Error!" },
"visible": { "$state": "/form/hasError" }
}
```
### ValidationProvider
Add field validation:
```vue
<ValidationProvider>
<!-- children -->
</ValidationProvider>
```
```typescript
// Use validation composable:
const { errors, validate } = useFieldValidation("/form/email", {
checks: [
{ type: "required", message: "Email required" },
{ type: "email", message: "Invalid email" },
],
});
```
## Composables
| Composable | Purpose |
|------------|---------|
| `useStateStore()` | Access state context (`state` as `ShallowRef`, `get`, `set`, `update`) |
| `useStateValue(path)` | Get single value from state |
| `useStateBinding(path)` | Two-way data binding (deprecated — use `$bindState` instead) |
| `useIsVisible(condition)` | Check if a visibility condition is met |
| `useActions()` | Access action context |
| `useAction(binding)` | Get a single action dispatch function |
| `useFieldValidation(path, config)` | Field validation state |
### `useStateStore()`
> **Note:** `useStateStore().state` returns a `ShallowRef<StateModel>` — use `state.value` to access the underlying object. This differs from the React renderer where `state` is a plain object.
## Visibility Conditions
Access the state context inside any component rendered within a `JSONUIProvider`.
```typescript
// Truthiness check
{ "$state": "/user/isAdmin" }
import { useStateStore } from "@json-render/vue";
// Auth state (use state path)
{ "$state": "/auth/isSignedIn" }
// Comparisons (flat style)
{ "$state": "/status", "eq": "active" }
{ "$state": "/count", "gt": 10 }
// Negation
{ "$state": "/maintenance", "not": true }
// Multiple conditions (implicit AND)
[
{ "$state": "/feature/enabled" },
{ "$state": "/maintenance", "not": true }
]
// Always / never
true // always visible
false // never visible
const { state, get, set, getSnapshot } = useStateStore();
```
TypeScript helpers from `@json-render/core`:
### `useBoundProp(propValue, bindingPath)`
Two-way binding helper for `$bindState` / `$bindItem` expressions.
```typescript
import { visibility } from "@json-render/core";
import { useBoundProp } from "@json-render/vue";
visibility.when("/path") // { $state: "/path" }
visibility.unless("/path") // { $state: "/path", not: true }
visibility.eq("/path", val) // { $state: "/path", eq: val }
visibility.neq("/path", val) // { $state: "/path", neq: val }
visibility.and(cond1, cond2) // { $and: [cond1, cond2] }
visibility.always // true
visibility.never // false
const [value, setValue] = useBoundProp<string>(props.value, bindings?.value);
```
### `useVisibility()`
Evaluate visibility conditions.
```typescript
import { useVisibility } from "@json-render/vue";
const { isVisible, ctx } = useVisibility();
```
### `useActions()`
Access action execution.
```typescript
import { useActions } from "@json-render/vue";
const { execute, loadingActions } = useActions();
```
### `useFieldValidation(path, config)`
Field-level validation.
```typescript
import { useFieldValidation } from "@json-render/vue";
const { errors, isValid, validate, touch } = useFieldValidation("/email", {
checks: [{ type: "required", message: "Email is required" }],
});
```
## Provider Components
All providers are renderless components that use Vue's `provide`/`inject`:
- **`StateProvider`** -- Manages state model with `createStateStore` from core
- **`VisibilityProvider`** -- Evaluates visibility conditions against state
- **`ValidationProvider`** -- Handles form validation
- **`ActionProvider`** -- Executes action bindings (setState, pushState, custom actions)
- **`JSONUIProvider`** -- Composes all of the above into a single provider
## Dynamic Prop Expressions
Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.
Props support dynamic expressions that resolve against state:
```json
{
"type": "Badge",
"props": {
"label": { "$state": "/user/role" },
"color": {
"$cond": { "$state": "/user/role", "eq": "admin" },
"$then": "red",
"$else": "gray"
}
}
}
```
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop. Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop.
See [@json-render/core](../core/README.md) for full expression syntax.
| Expression | Description |
|---|---|
| `{ "$state": "/path" }` | Read a value from state |
| `{ "$bindState": "/path" }` | Two-way binding to state |
| `{ "$item": "field" }` | Read from current repeat item |
| `{ "$bindItem": "field" }` | Two-way bind to repeat item field |
| `{ "$index": true }` | Current repeat index |
| `{ "$cond": ..., "$then": ..., "$else": ... }` | Conditional value |
| `{ "$computed": "fnName", "args": {...} }` | Computed function |
| `{ "$template": "Hello ${/name}" }` | String interpolation |
## Built-in Actions
The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the Vue schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:
```json
{
"type": "Button",
"props": { "label": "Switch Tab" },
"on": {
"press": {
"action": "setState",
"params": { "statePath": "/activeTab", "value": "settings" }
}
},
"children": []
}
```
## Component Props
When using `defineRegistry`, components receive these props via their render function:
```typescript
import type { VNode } from "vue";
interface ComponentContext<P> {
props: P; // Typed props from the catalog (expressions resolved)
children?: VNode | VNode[]; // Rendered children (for container components)
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the parent is loading
bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions
}
interface EventHandle {
emit: () => void; // Fire the event
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
bound: boolean; // Whether any handler is bound
}
```
Use `emit("press")` for simple event firing. Use `on("click")` when you need to check metadata like `shouldPreventDefault` or `bound`:
```typescript
Link: ({ props, on }) => {
const click = on("click");
return h("a", {
href: props.href,
onClick: (e: MouseEvent) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
},
}, props.label);
},
```
### `BaseComponentProps`
For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic `BaseComponentProps` type:
```typescript
import type { BaseComponentProps } from "@json-render/vue";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
h("div", null, [props.title, children]);
```
## Generate AI Prompts
```typescript
const systemPrompt = catalog.prompt();
// Returns detailed prompt with component/action descriptions
```
## Full Example
```typescript
import { h } from "vue";
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { defineRegistry, Renderer, StateProvider } from "@json-render/vue";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
Greeting: {
props: z.object({ name: z.string() }),
description: "Displays a greeting",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
},
});
const spec = {
root: "greeting-1",
elements: {
"greeting-1": {
type: "Greeting",
props: { name: "World" },
children: [],
},
},
};
// In your App.vue:
// <StateProvider>
// <Renderer :spec="spec" :registry="registry" />
// </StateProvider>
```
| Action | Description |
|---|---|
| `setState` | Set a value at a state path |
| `pushState` | Append to an array in state |
| `removeState` | Remove an item from an array by index |
| `validateForm` | Validate all registered fields |
## Key Exports
| Export | Purpose |
|--------|---------|
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) |
| `useStateStore` | Access state context (`state` is `ShallowRef<StateModel>`) |
| `useStateValue` | Get single value from state |
| `useActions` | Access actions context |
| `useAction` | Get a single action dispatch function |
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
```typescript
// Renderer
export { Renderer, JSONUIProvider, defineRegistry, createRenderer, useBoundProp };
### Types
// Composables
export { useStateStore, useStateValue };
export { useVisibility, useIsVisible };
export { useActions, useAction };
export { useValidation, useFieldValidation };
export { useRepeatScope };
| Export | Purpose |
|--------|---------|
| `ComponentContext` | Typed component render function context (catalog-aware) |
| `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
| `EventHandle` | Event handle with `emit()`, `shouldPreventDefault`, `bound` |
| `ActionProviderProps` | Props for `ActionProvider` |
| `ValidationProviderProps` | Props for `ValidationProvider` |
| `ComponentFn` | Component render function type |
| `SetState` | State setter type |
| `StateModel` | State model type |
| `StateStore` | Interface for plugging in external state management |
// Provider components
export { StateProvider, VisibilityProvider, ActionProvider, ValidationProvider };
export { RepeatScopeProvider };
## Differences from `@json-render/react`
// Schema
export { schema };
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
// Types
export type {
ComponentRenderProps, ComponentRenderer, ComponentRegistry,
RendererProps, JSONUIProviderProps, CreateRendererProps,
BaseComponentProps, ComponentContext, ComponentFn,
EventHandle, SetState, StateModel,
};
```
+13 -37
View File
@@ -1,44 +1,22 @@
{
"name": "@json-render/vue",
"version": "0.9.1",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
"keywords": [
"json",
"ui",
"vue",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"components"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/vue"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
"import": "./dist/schema.js",
"require": "./dist/schema.cjs"
}
},
"files": [
@@ -47,20 +25,18 @@
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit"
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@vue/test-utils": "^2.4.6",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"vue": "^3.5.0"
"tsup": "^8.5.1",
"typescript": "^5.7.2",
"vue": "^3.5.29"
},
"peerDependencies": {
"vue": "^3.5.0",
"zod": "^4.0.0"
"vue": "^3.5.0"
}
}
+2 -89
View File
@@ -10,114 +10,39 @@ import type {
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 = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
* h('div', null, [props.title, children])
* ```
*/
export interface BaseComponentProps<P = Record<string, unknown>> {
props: P;
/** Rendered children (from the default slot) */
children?: VNode | VNode[];
/** Simple event emitter (shorthand). Fires the event and returns void. */
children?: VNode[];
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 h('button', { onClick: () => ctx.emit("press") }, ctx.props.label)
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> extends BaseComponentProps<InferComponentProps<C, K>> {}
/**
* Component render function type for Vue
* @example
* const Button: ComponentFn<typeof catalog, 'Button'> = ({ props, emit }) =>
* h('button', { onClick: () => emit("press") }, props.label)
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null | string;
> = (ctx: ComponentContext<C, K>) => VNode | VNode[] | null;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Button: ({ props }) => h('button', null, props.label),
* Input: ({ props }) => h('input', { placeholder: 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>,
@@ -127,22 +52,10 @@ export type ActionFn<
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]
+190 -80
View File
@@ -1,106 +1,216 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider, useStateStore } from "./state";
import { ActionProvider, useActions, useAction } from "./actions";
import { defineComponent, h, createApp, nextTick } from "vue";
import { ActionProvider, useActions } from "./actions.js";
import { StateProvider, useStateStore } from "./state.js";
import { VisibilityProvider } from "./visibility.js";
import { ValidationProvider } from "./validation.js";
/** Mount StateProvider → ActionProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
handlers: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {},
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
function runInProviders<T>(
initialState: Record<string, unknown>,
fn: () => T,
handlers?: Record<string, (...args: unknown[]) => void>,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = composable();
return () => h("div");
result = fn();
return () => null;
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(ActionProvider as Component, { handlers } as any, {
default: () => h(Child),
}),
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: handlers ?? {} }, () => h(Inner)),
),
),
);
},
});
return { result };
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("ActionProvider — provide/inject", () => {
it("useActions() throws outside a provider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useActions()).toThrow(
"useActions must be used within an ActionProvider",
);
warn.mockRestore();
describe("useActions", () => {
it("provides execute function", () => {
const ctx = runInProviders({}, () => useActions());
expect(typeof ctx.execute).toBe("function");
});
it("provides confirm and cancel functions", () => {
const ctx = runInProviders({}, () => useActions());
expect(typeof ctx.confirm).toBe("function");
expect(typeof ctx.cancel).toBe("function");
});
});
describe("ActionProvider — custom handler dispatch", () => {
it("calling execute invokes the registered handler", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { result } = withProviders(() => useActions(), { myAction: handler });
await result.execute({ action: "myAction", params: { x: 1 } });
expect(handler).toHaveBeenCalledOnce();
});
describe("action execution", () => {
it("executes setState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
it("handler receives the resolved params", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const { result } = withProviders(() => useActions(), { myAction: handler });
await result.execute({ action: "myAction", params: { x: 1, y: "hello" } });
expect(handler).toHaveBeenCalledWith({ x: 1, y: "hello" });
});
it("console.warn is called for unknown actions", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const { result } = withProviders(() => useActions());
await result.execute({ action: "unknownAction" });
expect(warn).toHaveBeenCalledWith(expect.stringContaining("unknownAction"));
warn.mockRestore();
});
});
describe("ActionProvider — built-in setState integration", () => {
it("executing setState updates state via the provider chain", async () => {
let stateCtx!: ReturnType<typeof useStateStore>;
let actionsCtx!: ReturnType<typeof useActions>;
const Child = defineComponent({
const Inner = defineComponent({
setup() {
stateCtx = useStateStore();
storeCtx = useStateStore();
actionsCtx = useActions();
return () => h("div");
return () => null;
},
});
mount(StateProvider as Component, {
props: { initialState: { v: 0 } } as any,
slots: {
default: () =>
h(ActionProvider as Component, null, {
default: () => h(Child),
}),
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { count: 0 } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
await actionsCtx.execute({
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "setState",
params: { statePath: "/v", value: 42 },
params: { statePath: "/count", value: 42 },
});
expect(stateCtx.state.value).toEqual({ v: 42 });
});
});
describe("useAction", () => {
it("returns { execute, isLoading: false } before execution", () => {
const { result } = withProviders(() => useAction({ action: "myAction" }));
expect(typeof result.execute).toBe("function");
expect(result.isLoading.value).toBe(false);
expect(storeCtx!.get("/count")).toBe(42);
app.unmount();
});
it("executes pushState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
storeCtx = useStateStore();
actionsCtx = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { items: ["a"] } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "pushState",
params: { statePath: "/items", value: "b" },
});
expect(storeCtx!.get("/items")).toEqual(["a", "b"]);
app.unmount();
});
it("executes removeState action", async () => {
let storeCtx: ReturnType<typeof useStateStore> | undefined;
let actionsCtx: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
storeCtx = useStateStore();
actionsCtx = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: { items: ["a", "b", "c"] } }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actionsCtx!.execute({
action: "removeState",
params: { statePath: "/items", index: 1 },
});
expect(storeCtx!.get("/items")).toEqual(["a", "c"]);
app.unmount();
});
it("warns when no handler is registered for custom action", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const actionsCtx = runInProviders({}, () => useActions());
// The provider is unmounted, but execute still runs synchronously for the warn path
// We need a mounted tree for this test
let actions: ReturnType<typeof useActions> | undefined;
const Inner = defineComponent({
setup() {
actions = useActions();
return () => null;
},
});
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState: {} }, () =>
h(VisibilityProvider, null, () =>
h(ValidationProvider, null, () =>
h(ActionProvider, { handlers: {} }, () => h(Inner)),
),
),
);
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
await actions!.execute({ action: "unknownAction" });
expect(warnSpy).toHaveBeenCalledWith(
"No handler registered for action: unknownAction",
);
warnSpy.mockRestore();
app.unmount();
});
});
+143 -174
View File
@@ -1,12 +1,10 @@
import {
computed,
defineComponent,
h,
inject,
provide,
inject,
ref,
watch,
type ComputedRef,
type InjectionKey,
type PropType,
} from "vue";
import {
@@ -17,29 +15,27 @@ import {
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
import { useStateStore } from "./state.js";
import { useOptionalValidation } from "./validation.js";
/**
* Generate a unique ID for use with the "$id" token.
*/
let idCounter = 0;
function generateUniqueId(): string {
idCounter += 1;
return `${Date.now()}-${idCounter}`;
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
/**
* Deep-resolve dynamic value references within an object.
*
* Supported tokens:
* - `{ $state: "/statePath" }` - read a value from state
* - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
*/
const MAX_RESOLVE_DEPTH = 20;
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
depth: number = 0,
): unknown {
if (depth > MAX_RESOLVE_DEPTH) return value;
if (value === null || value === undefined) return value;
if (value === "$id") {
@@ -60,13 +56,13 @@ function deepResolveValue(
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get));
return value.map((item) => deepResolveValue(item, get, depth + 1));
}
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);
resolved[key] = deepResolveValue(val, get, depth + 1);
}
return resolved;
}
@@ -74,9 +70,6 @@ function deepResolveValue(
return value;
}
/**
* Pending confirmation state
*/
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
@@ -84,9 +77,6 @@ export interface PendingConfirmation {
reject: () => void;
}
/**
* Action context value
*/
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
@@ -97,16 +87,15 @@ export interface ActionContextValue {
registerHandler: (name: string, handler: ActionHandler) => void;
}
const ACTIONS_KEY = Symbol("json-render:actions");
export const ActionKey: InjectionKey<ActionContextValue> = Symbol(
"json-render-actions",
);
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
}
/**
* Provider for action execution
*/
export const ActionProvider = defineComponent({
name: "ActionProvider",
props: {
@@ -120,63 +109,54 @@ export const ActionProvider = defineComponent({
},
},
setup(props, { slots }) {
const { get, set, getSnapshot } = useStateStore();
const storeCtx = useStateStore();
const validation = useOptionalValidation();
const handlers = ref<Record<string, ActionHandler>>(props.handlers ?? {});
const loadingActions = ref<Set<string>>(new Set());
const pendingConfirmation = ref<PendingConfirmation | null>(null);
// Sync handlers when prop changes
watch(
() => props.handlers,
(newHandlers) => {
if (newHandlers) handlers.value = newHandlers;
},
);
const handlersRef = ref<Record<string, ActionHandler>>({
...props.handlers,
});
const loadingActionsRef = ref<Set<string>>(new Set());
const pendingConfirmationRef = ref<PendingConfirmation | null>(null);
const registerHandler = (name: string, handler: ActionHandler) => {
handlers.value = { ...handlers.value, [name]: handler };
handlersRef.value = { ...handlersRef.value, [name]: handler };
};
const execute = async (binding: ActionBinding): Promise<void> => {
const resolved = resolveAction(binding, getSnapshot());
const execute = async (binding: ActionBinding) => {
const resolved = resolveAction(binding, storeCtx.getSnapshot());
// Built-in: setState
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
storeCtx.set(statePath, value);
}
return;
}
// Built-in: pushState
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 resolvedValue = deepResolveValue(rawValue, storeCtx.get);
const arr = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
storeCtx.set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
storeCtx.set(clearStatePath, "");
}
}
return;
}
// Built-in: removeState
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(
const arr = (storeCtx.get(statePath) as unknown[] | undefined) ?? [];
storeCtx.set(
statePath,
arr.filter((_, i) => i !== index),
);
@@ -184,92 +164,105 @@ export const ActionProvider = defineComponent({
return;
}
// Built-in: validateForm — triggers validateAll and writes result to state
if (resolved.action === "push" && resolved.params) {
const screen = resolved.params.screen as string;
if (screen) {
const currentScreen = storeCtx.get("/currentScreen") as
| string
| undefined;
const navStack =
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
if (currentScreen) {
storeCtx.set("/navStack", [...navStack, currentScreen]);
} else {
storeCtx.set("/navStack", [...navStack, ""]);
}
storeCtx.set("/currentScreen", screen);
}
return;
}
if (resolved.action === "pop") {
const navStack =
(storeCtx.get("/navStack") as string[] | undefined) ?? [];
if (navStack.length > 0) {
const previousScreen = navStack[navStack.length - 1];
storeCtx.set("/navStack", navStack.slice(0, -1));
if (previousScreen) {
storeCtx.set("/currentScreen", previousScreen);
} else {
storeCtx.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.",
"validateForm action was dispatched but no ValidationProvider is connected.",
);
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 });
storeCtx.set(statePath, { valid });
return;
}
// Built-in: push (navigation)
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;
}
// Built-in: pop (navigation)
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;
}
const handler = handlers.value[resolved.action];
const handler = handlersRef.value[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
// If confirmation is required, show dialog first
if (resolved.confirm) {
await new Promise<void>((resolve, reject) => {
pendingConfirmation.value = {
return new Promise<void>((resolve, reject) => {
pendingConfirmationRef.value = {
action: resolved,
handler,
resolve: () => {
pendingConfirmation.value = null;
pendingConfirmationRef.value = null;
resolve();
},
reject: () => {
pendingConfirmation.value = null;
pendingConfirmationRef.value = null;
reject(new Error("Action cancelled"));
},
};
}).then(async () => {
const next = new Set(loadingActionsRef.value);
next.add(resolved.action);
loadingActionsRef.value = next;
try {
await executeAction({
action: resolved,
handler,
setState: storeCtx.set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const fin = new Set(loadingActionsRef.value);
fin.delete(resolved.action);
loadingActionsRef.value = fin;
}
});
const addLoading = new Set(loadingActions.value);
addLoading.add(resolved.action);
loadingActions.value = addLoading;
} else {
const next = new Set(loadingActionsRef.value);
next.add(resolved.action);
loadingActionsRef.value = next;
try {
await executeAction({
action: resolved,
handler,
setState: set,
setState: storeCtx.set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
@@ -277,114 +270,90 @@ export const ActionProvider = defineComponent({
},
});
} finally {
const removeLoading = new Set(loadingActions.value);
removeLoading.delete(resolved.action);
loadingActions.value = removeLoading;
const fin = new Set(loadingActionsRef.value);
fin.delete(resolved.action);
loadingActionsRef.value = fin;
}
return;
}
// Execute immediately
const addLoading = new Set(loadingActions.value);
addLoading.add(resolved.action);
loadingActions.value = addLoading;
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: props.navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
const removeLoading = new Set(loadingActions.value);
removeLoading.delete(resolved.action);
loadingActions.value = removeLoading;
}
};
const confirm = () => pendingConfirmation.value?.resolve();
const cancel = () => pendingConfirmation.value?.reject();
const confirm = () => {
pendingConfirmationRef.value?.resolve();
};
provide<ActionContextValue>(ACTIONS_KEY, {
const cancel = () => {
pendingConfirmationRef.value?.reject();
};
const ctx: ActionContextValue = {
get handlers() {
return handlers.value;
return handlersRef.value;
},
get loadingActions() {
return loadingActions.value;
return loadingActionsRef.value;
},
get pendingConfirmation() {
return pendingConfirmation.value;
return pendingConfirmationRef.value;
},
execute,
confirm,
cancel,
registerHandler,
});
};
provide(ActionKey, ctx);
return () => slots.default?.();
},
});
/**
* Composable to access action context
*/
export function useActions(): ActionContextValue {
const ctx = inject<ActionContextValue>(ACTIONS_KEY);
const ctx = inject(ActionKey);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
/**
* Composable to execute an action binding
*/
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: ComputedRef<boolean>;
isLoading: boolean;
} {
const ctx = useActions();
const { execute, loadingActions } = useActions();
return {
execute: () => ctx.execute(binding),
isLoading: computed(() => ctx.loadingActions.has(binding.action)),
execute: () => execute(binding),
get isLoading() {
return loadingActions.has(binding.action);
},
};
}
// =============================================================================
// ConfirmDialog component
// =============================================================================
// ---------------------------------------------------------------------------
// ConfirmDialog
// ---------------------------------------------------------------------------
/**
* Props for ConfirmDialog component
*/
export interface ConfirmDialogProps {
confirm: ActionConfirm;
confirm: {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: string;
};
onConfirm: () => void;
onCancel: () => void;
}
/**
* Default confirmation dialog component
*/
export const ConfirmDialog = defineComponent({
name: "ConfirmDialog",
props: {
confirm: {
type: Object as PropType<ActionConfirm>,
required: true,
},
onConfirm: {
type: Function as PropType<() => void>,
required: true,
},
onCancel: {
type: Function as PropType<() => void>,
type: Object as PropType<ConfirmDialogProps["confirm"]>,
required: true,
},
onConfirm: { type: Function as PropType<() => void>, required: true },
onCancel: { type: Function as PropType<() => void>, required: true },
},
setup(props) {
return () => {
@@ -400,7 +369,7 @@ export const ConfirmDialog = defineComponent({
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: "50",
zIndex: 50,
},
onClick: props.onCancel,
},
@@ -416,7 +385,7 @@ export const ConfirmDialog = defineComponent({
width: "100%",
boxShadow: "0 20px 25px -5px rgba(0, 0, 0, 0.1)",
},
onClick: (e: MouseEvent) => e.stopPropagation(),
onClick: (e: Event) => e.stopPropagation(),
},
[
h(
@@ -450,6 +419,7 @@ export const ConfirmDialog = defineComponent({
h(
"button",
{
onClick: props.onCancel,
style: {
padding: "8px 16px",
borderRadius: "6px",
@@ -457,13 +427,13 @@ export const ConfirmDialog = defineComponent({
backgroundColor: "white",
cursor: "pointer",
},
onClick: props.onCancel,
},
props.confirm.cancelLabel ?? "Cancel",
),
h(
"button",
{
onClick: props.onConfirm,
style: {
padding: "8px 16px",
borderRadius: "6px",
@@ -472,7 +442,6 @@ export const ConfirmDialog = defineComponent({
color: "white",
cursor: "pointer",
},
onClick: props.onConfirm,
},
props.confirm.confirmLabel ?? "Confirm",
),
+27 -22
View File
@@ -1,26 +1,26 @@
import { defineComponent, inject, provide, type PropType } from "vue";
import {
defineComponent,
provide,
inject,
type InjectionKey,
type PropType,
} from "vue";
/**
* Repeat scope value provided to child elements inside a repeated element.
*/
export interface RepeatScopeValue {
/** The current array item object */
item: unknown;
/** Index of the current item in the array */
index: number;
/** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
basePath: string;
}
const REPEAT_SCOPE_KEY = Symbol("json-render:repeat-scope");
export const RepeatScopeKey: InjectionKey<RepeatScopeValue | null> = Symbol(
"json-render-repeat-scope",
);
/**
* Provides repeat scope to child elements so $item and $index expressions resolve correctly.
*/
export const RepeatScopeProvider = defineComponent({
name: "RepeatScopeProvider",
props: {
item: {
type: null as unknown as PropType<unknown>,
required: true,
},
index: {
@@ -28,24 +28,29 @@ export const RepeatScopeProvider = defineComponent({
required: true,
},
basePath: {
type: String as PropType<string>,
type: String,
required: true,
},
},
setup(props, { slots }) {
provide(REPEAT_SCOPE_KEY, props as RepeatScopeValue);
const value: RepeatScopeValue = {
get item() {
return props.item;
},
get index() {
return props.index;
},
get basePath() {
return props.basePath;
},
};
provide(RepeatScopeKey, value);
return () => slots.default?.();
},
});
/**
* Read the current repeat scope (or null if not inside a repeated element).
*/
export function useRepeatScope(): RepeatScopeValue | null {
return (
inject<RepeatScopeValue>(
REPEAT_SCOPE_KEY,
null as unknown as RepeatScopeValue,
) ?? null
);
return inject(RepeatScopeKey, null);
}
+81 -121
View File
@@ -1,139 +1,99 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { createStateStore } from "@json-render/core";
import { StateProvider, useStateStore } from "./state";
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp, nextTick } from "vue";
import {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
} from "./state.js";
/** Mount a StateProvider with a child that captures the injected context. */
function withProvider<T>(
composable: () => T,
props: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
function runInStateProvider<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = composable();
return () => h("div");
result = fn();
return () => null;
},
});
mount(StateProvider as Component, {
props: props as any,
slots: { default: () => h(Child) },
const App = defineComponent({
setup() {
return () => h(StateProvider, { initialState }, () => h(Inner));
},
});
return { result };
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("StateProvider — provide/inject", () => {
it("useStateStore() throws outside a provider", () => {
// inject() returns undefined outside of component setup; our guard throws
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useStateStore()).toThrow(
"useStateStore must be used within a StateProvider",
describe("StateProvider + useStateStore", () => {
it("provides initial state and get/set round-trip", () => {
const ctx = runInStateProvider({ count: 0 }, () => useStateStore());
expect(ctx.get("/count")).toBe(0);
ctx.set("/count", 42);
expect(ctx.get("/count")).toBe(42);
});
it("state reflects initial values", () => {
const ctx = runInStateProvider({ name: "Alice" }, () => useStateStore());
expect(ctx.state.name).toBe("Alice");
});
it("getSnapshot returns current state", () => {
const ctx = runInStateProvider({ x: 1 }, () => useStateStore());
expect(ctx.getSnapshot().x).toBe(1);
ctx.set("/x", 99);
expect(ctx.getSnapshot().x).toBe(99);
});
it("update applies multiple changes", () => {
const ctx = runInStateProvider({ a: 1, b: 2 }, () => useStateStore());
ctx.update({ "/a": 10, "/b": 20 });
expect(ctx.get("/a")).toBe(10);
expect(ctx.get("/b")).toBe(20);
});
});
describe("useStateValue", () => {
it("reads a value from state", () => {
const value = runInStateProvider({ name: "Alice" }, () =>
useStateValue<string>("/name"),
);
warn.mockRestore();
expect(value).toBe("Alice");
});
it("child receives the context from StateProvider", () => {
const { result } = withProvider(() => useStateStore());
expect(result).toBeDefined();
expect(result.state).toBeDefined();
expect(typeof result.get).toBe("function");
expect(typeof result.set).toBe("function");
expect(typeof result.update).toBe("function");
});
it("state.value is a plain object (not a wrapper type)", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { x: 1 },
});
expect(result.state.value).toEqual({ x: 1 });
// Should be a plain object, not a Vue Proxy of a ShallowRef wrapper
expect(typeof result.state.value).toBe("object");
});
});
describe("StateProvider (uncontrolled) — reactivity", () => {
it("state.value reflects initialState on mount", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { count: 5 },
});
expect(result.state.value).toEqual({ count: 5 });
});
it("after set(), state.value is updated synchronously", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: { x: 0 },
});
result.set("/x", 42);
expect(result.state.value).toEqual({ x: 42 });
});
it("after update(), all paths are reflected in state.value", () => {
const { result } = withProvider(() => useStateStore(), {
initialState: {},
});
result.update({ "/a": 1, "/b": "hello" });
expect(result.state.value).toEqual({ a: 1, b: "hello" });
});
it("onStateChange is fired with the changes array on set", () => {
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
initialState: {},
onStateChange: onChange,
});
result.set("/name", "Alice");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith([{ path: "/name", value: "Alice" }]);
});
it("onStateChange is fired once with all changed entries on update", () => {
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
initialState: {},
onStateChange: onChange,
});
result.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("returns undefined for missing path", () => {
const value = runInStateProvider({}, () =>
useStateValue<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
describe("StateProvider (controlled mode)", () => {
it("state.value reads from the external store snapshot", () => {
const store = createStateStore({ x: 10 });
const { result } = withProvider(() => useStateStore(), { store });
expect(result.state.value).toEqual({ x: 10 });
describe("useStateBinding", () => {
it("returns value from state and a setter", () => {
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
useStateBinding<number>("/x"),
);
expect(value).toBe(42);
expect(typeof setValue).toBe("function");
});
it("set() writes through to the external store", () => {
const store = createStateStore({ x: 0 });
const { result } = withProvider(() => useStateStore(), { store });
result.set("/x", 99);
expect(store.getSnapshot()).toEqual({ x: 99 });
});
it("external store mutation triggers state.value update", () => {
const store = createStateStore({ x: 0 });
const { result } = withProvider(() => useStateStore(), { store });
store.set("/x", 99);
expect(result.state.value).toEqual({ x: 99 });
});
it("onStateChange is NOT called in controlled mode", () => {
const store = createStateStore({ x: 0 });
const onChange = vi.fn();
const { result } = withProvider(() => useStateStore(), {
store,
onStateChange: onChange,
});
result.set("/x", 99);
expect(onChange).not.toHaveBeenCalled();
it("returns undefined for missing path", () => {
const [value] = runInStateProvider({}, () =>
useStateBinding<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
+46 -96
View File
@@ -1,69 +1,38 @@
import {
computed,
defineComponent,
inject,
onUnmounted,
provide,
ref,
inject,
shallowRef,
onBeforeUnmount,
watch,
type ComputedRef,
type InjectionKey,
type PropType,
type ShallowRef,
} from "vue";
import {
createStateStore,
getByPath,
createStateStore,
type StateModel,
type StateStore,
} from "@json-render/core";
import { flattenToPointers } from "@json-render/core/store-utils";
/**
* State context value
*/
export interface StateContextValue {
/** Reactive state snapshot — use state.value in reactive contexts */
state: ShallowRef<StateModel>;
/** Get a value by path */
state: StateModel;
get: (path: string) => unknown;
/** Set a value by path */
set: (path: string, value: unknown) => void;
/** Update multiple values at once */
update: (updates: Record<string, unknown>) => void;
/** Return the live state snapshot from the underlying store (not through Vue reactivity). */
getSnapshot: () => StateModel;
}
const STATE_KEY = Symbol("json-render:state");
export const StateKey: InjectionKey<StateContextValue> =
Symbol("json-render-state");
/**
* Props for StateProvider
*/
export interface StateProviderProps {
/**
* External store that owns the state. When provided, the provider operates
* in **controlled mode** — `initialState` and `onStateChange` are ignored
* and the store is the single source of truth.
*/
store?: StateStore;
/** Initial state model (used only in uncontrolled mode) */
initialState?: StateModel;
/**
* Callback when state changes (used only in uncontrolled mode).
* Called once per `set` or `update` with all changed entries.
*/
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
}
/**
* Provider for state model context.
*
* Supports two modes:
* - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
* - **Uncontrolled** (default): omit `store` and optionally pass
* `initialState` / `onStateChange`.
*/
export const StateProvider = defineComponent({
name: "StateProvider",
props: {
@@ -73,7 +42,7 @@ export const StateProvider = defineComponent({
},
initialState: {
type: Object as PropType<StateModel>,
default: undefined,
default: () => ({}),
},
onStateChange: {
type: Function as PropType<
@@ -84,21 +53,21 @@ export const StateProvider = defineComponent({
},
setup(props, { slots }) {
const isControlled = !!props.store;
let internalStore: StateStore | undefined;
if (!isControlled) {
internalStore = createStateStore(props.initialState);
}
// Use external store (controlled) or create internal store (uncontrolled)
const internalStore = !isControlled
? createStateStore(props.initialState ?? {})
: null;
const store: StateStore = props.store ?? internalStore!;
const activeStore = props.store ?? internalStore!;
const state = shallowRef<StateModel>(store.getSnapshot());
const stateRef = shallowRef<StateModel>(activeStore.getSnapshot());
const unsubscribe = store.subscribe(() => {
state.value = store.getSnapshot();
const unsub = activeStore.subscribe(() => {
stateRef.value = activeStore.getSnapshot();
});
onUnmounted(unsubscribe);
onBeforeUnmount(unsub);
// Sync external initialState changes (uncontrolled mode only)
// Sync initialState changes for uncontrolled mode
if (!isControlled) {
let prevFlat: Record<string, unknown> =
props.initialState && Object.keys(props.initialState).length > 0
@@ -107,12 +76,10 @@ export const StateProvider = defineComponent({
watch(
() => props.initialState,
(newInitialState) => {
if (!newInitialState) return;
(newState) => {
if (isControlled || !newState) return;
const nextFlat =
Object.keys(newInitialState).length > 0
? flattenToPointers(newInitialState)
: {};
Object.keys(newState).length > 0 ? flattenToPointers(newState) : {};
const allKeys = new Set([
...Object.keys(prevFlat),
...Object.keys(nextFlat),
@@ -125,36 +92,27 @@ export const StateProvider = defineComponent({
}
prevFlat = nextFlat;
if (Object.keys(updates).length > 0) {
store.update(updates);
activeStore.update(updates);
}
},
{ deep: true },
);
}
// Keep onStateChange in a ref so it always reads the latest callback
const onStateChangeRef = ref(props.onStateChange);
watch(
() => props.onStateChange,
(fn) => {
onStateChangeRef.value = fn;
},
);
const get = (path: string) => store.get(path);
const getSnapshot = () => store.getSnapshot();
const get = (path: string) => activeStore.get(path);
const set = (path: string, value: unknown) => {
const prev = store.getSnapshot();
store.set(path, value);
if (!isControlled && store.getSnapshot() !== prev) {
onStateChangeRef.value?.([{ path, value }]);
const prev = activeStore.getSnapshot();
activeStore.set(path, value);
if (!isControlled && activeStore.getSnapshot() !== prev) {
props.onStateChange?.([{ path, value }]);
}
};
const update = (updates: Record<string, unknown>) => {
const prev = store.getSnapshot();
store.update(updates);
if (!isControlled && store.getSnapshot() !== prev) {
const prev = activeStore.getSnapshot();
activeStore.update(updates);
if (!isControlled && activeStore.getSnapshot() !== prev) {
const changes: Array<{ path: string; value: unknown }> = [];
for (const [path, value] of Object.entries(updates)) {
if (getByPath(prev, path) !== value) {
@@ -162,55 +120,47 @@ export const StateProvider = defineComponent({
}
}
if (changes.length > 0) {
onStateChangeRef.value?.(changes);
props.onStateChange?.(changes);
}
}
};
provide<StateContextValue>(STATE_KEY, {
state,
const getSnapshot = () => activeStore.getSnapshot();
const ctx: StateContextValue = {
get state() {
return stateRef.value;
},
get,
set,
update,
getSnapshot,
});
};
provide(StateKey, ctx);
return () => slots.default?.();
},
});
/**
* Composable to access the state context
*/
export function useStateStore(): StateContextValue {
const ctx = inject<StateContextValue>(STATE_KEY);
const ctx = inject(StateKey);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
/**
* Composable to get a value from the state model (reactive)
*/
export function useStateValue<T>(path: string): ComputedRef<T | undefined> {
export function useStateValue<T>(path: string): T | undefined {
const { state } = useStateStore();
return computed(() => getByPath(state.value, path) as T | undefined);
return getByPath(state, path) as T | undefined;
}
/**
* Composable to get and set a value from the state model by path.
*
* This is the path-based variant for use in arbitrary composables. For
* registry components that receive `bindings` from the renderer, prefer
* `useBoundProp` which reads the already-resolved prop value and writes back
* to the bound path.
*/
export function useStateBinding<T>(
path: string,
): [ComputedRef<T | undefined>, (value: T) => void] {
): [T | undefined, (value: T) => void] {
const { state, set } = useStateStore();
const value = computed(() => getByPath(state.value, path) as T | undefined);
const value = getByPath(state, path) as T | undefined;
const setValue = (newValue: T) => set(path, newValue);
return [value, setValue];
}
@@ -1,209 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider } from "./state";
import {
ValidationProvider,
useOptionalValidation,
useValidation,
useFieldValidation,
} from "./validation";
/** Mount StateProvider → ValidationProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
return { result };
}
/** Mount only inside StateProvider (no ValidationProvider) */
function withStateOnly<T>(composable: () => T): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
return { result };
}
describe("ValidationProvider — provide/inject", () => {
it("useValidation() throws outside a provider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useValidation()).toThrow(
"useValidation must be used within a ValidationProvider",
);
warn.mockRestore();
});
});
describe("useOptionalValidation", () => {
it("returns null outside a ValidationProvider", () => {
const { result } = withStateOnly(() => useOptionalValidation());
expect(result).toBeNull();
});
it("returns context inside a 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("after validate(), isValid and errors reflect the result", () => {
const { result } = withProviders(
() =>
useFieldValidation("/name", {
checks: [{ type: "required", message: "Name is required" }],
}),
{ name: "" },
);
const validationResult = result.validate();
expect(validationResult.valid).toBe(false);
expect(validationResult.errors).toContain("Name is required");
});
it("after validate() with valid value, isValid is true", () => {
const { result } = withProviders(
() =>
useFieldValidation("/name", {
checks: [{ type: "required", message: "Name is required" }],
}),
{ name: "Alice" },
);
const validationResult = result.validate();
expect(validationResult.valid).toBe(true);
expect(validationResult.errors).toHaveLength(0);
});
it("touch() sets touched: true in the validation context", () => {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
fieldCtx = useFieldValidation("/email");
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
fieldCtx.touch();
expect(validationCtx.fieldStates["/email"]?.touched).toBe(true);
});
it("clear() resets the field state from the validation context", () => {
let validationCtx!: ReturnType<typeof useValidation>;
let fieldCtx!: ReturnType<typeof useFieldValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
fieldCtx = useFieldValidation("/email", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { email: "" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
fieldCtx.validate(); // populate fieldStates
fieldCtx.clear();
expect(validationCtx.fieldStates["/email"]).toBeUndefined();
});
it("validateAll() returns true when all registered fields pass", () => {
let validationCtx!: ReturnType<typeof useValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
useFieldValidation("/name", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "Alice" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(validationCtx.validateAll()).toBe(true);
});
it("validateAll() returns false when any field fails", () => {
let validationCtx!: ReturnType<typeof useValidation>;
const Child = defineComponent({
setup() {
validationCtx = useValidation();
useFieldValidation("/name", {
checks: [{ type: "required", message: "Required" }],
});
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "" } } as any,
slots: {
default: () =>
h(ValidationProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(validationCtx.validateAll()).toBe(false);
});
});
+34 -110
View File
@@ -1,13 +1,13 @@
import {
computed,
defineComponent,
inject,
onMounted,
onUnmounted,
provide,
inject,
ref,
type ComputedRef,
computed,
watchEffect,
type InjectionKey,
type PropType,
type ComputedRef,
} from "vue";
import {
runValidation,
@@ -15,20 +15,14 @@ import {
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useStateStore } from "./state.js";
/**
* Field validation state
*/
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
/**
* Validation context value
*/
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
@@ -39,68 +33,14 @@ export interface ValidationContextValue {
registerField: (path: string, config: ValidationConfig) => void;
}
const VALIDATION_KEY = Symbol("json-render:validation");
export const ValidationKey: InjectionKey<ValidationContextValue> = Symbol(
"json-render-validation",
);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
}
/**
* Compare two args records shallowly.
*/
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;
}
/**
* Structural equality check for ValidationConfig.
*/
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;
}
/**
* Provider for validation
*/
export const ValidationProvider = defineComponent({
name: "ValidationProvider",
props: {
@@ -110,14 +50,11 @@ export const ValidationProvider = defineComponent({
},
},
setup(props, { slots }) {
const { state } = useStateStore();
const storeCtx = useStateStore();
const fieldStates = ref<Record<string, FieldValidationState>>({});
const fieldConfigs = ref<Record<string, ValidationConfig>>({});
const registerField = (path: string, config: ValidationConfig) => {
const existing = fieldConfigs.value[path];
if (existing && validationConfigEqual(existing, config)) return;
fieldConfigs.value = { ...fieldConfigs.value, [path]: config };
};
@@ -125,7 +62,7 @@ export const ValidationProvider = defineComponent({
path: string,
config: ValidationConfig,
): ValidationResult => {
const currentState = state.value;
const currentState = storeCtx.getSnapshot();
const segments = path.split("/").filter(Boolean);
let value: unknown = currentState;
for (const seg of segments) {
@@ -155,13 +92,13 @@ export const ValidationProvider = defineComponent({
};
const touch = (path: string) => {
const existing = fieldStates.value[path];
fieldStates.value = {
...fieldStates.value,
[path]: {
...fieldStates.value[path],
touched: true,
validated: fieldStates.value[path]?.validated ?? false,
result: fieldStates.value[path]?.result ?? null,
validated: existing?.validated ?? false,
result: existing?.result ?? null,
},
};
};
@@ -182,7 +119,7 @@ export const ValidationProvider = defineComponent({
return allValid;
};
provide<ValidationContextValue>(VALIDATION_KEY, {
const ctx: ValidationContextValue = {
get customFunctions() {
return props.customFunctions;
},
@@ -194,39 +131,26 @@ export const ValidationProvider = defineComponent({
clear,
validateAll,
registerField,
});
};
provide(ValidationKey, ctx);
return () => slots.default?.();
},
});
/**
* Composable to access validation context (or null if not inside a ValidationProvider).
* Useful for components that optionally participate in form validation.
*/
export function useOptionalValidation(): ValidationContextValue | null {
return (
inject<ValidationContextValue>(
VALIDATION_KEY,
null as unknown as ValidationContextValue,
) ?? null
);
}
/**
* Composable to access validation context
*/
export function useValidation(): ValidationContextValue {
const ctx = inject<ValidationContextValue>(VALIDATION_KEY);
const ctx = inject(ValidationKey);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
/**
* Composable to get validation state for a field
*/
export function useOptionalValidation(): ValidationContextValue | null {
return inject(ValidationKey, null);
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
@@ -240,15 +164,11 @@ export function useFieldValidation(
} {
const ctx = useValidation();
onMounted(() => {
if (path && config) {
if (path && config) {
watchEffect(() => {
ctx.registerField(path, config);
}
});
onUnmounted(() => {
ctx.clear(path);
});
});
}
const defaultState: FieldValidationState = {
touched: false,
@@ -256,12 +176,16 @@ export function useFieldValidation(
result: null,
};
const state = computed(() => ctx.fieldStates[path] ?? defaultState);
const errors = computed(() => state.value.result?.errors ?? []);
const isValid = computed(() => state.value.result?.valid ?? true);
return {
state: computed(() => ctx.fieldStates[path] ?? defaultState),
state,
validate: () => ctx.validate(path, config ?? { checks: [] }),
touch: () => ctx.touch(path),
clear: () => ctx.clear(path),
errors: computed(() => ctx.fieldStates[path]?.result?.errors ?? []),
isValid: computed(() => ctx.fieldStates[path]?.result?.valid ?? true),
errors,
isValid,
};
}
+80 -84
View File
@@ -1,97 +1,93 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component, type ComputedRef } from "vue";
import { mount } from "@vue/test-utils";
import { StateProvider, useStateStore } from "./state";
import { VisibilityProvider, useVisibility, useIsVisible } from "./visibility";
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp } from "vue";
import {
VisibilityProvider,
useVisibility,
useIsVisible,
} from "./visibility.js";
import { StateProvider } from "./state.js";
/** Mount StateProvider → VisibilityProvider with a child that captures context. */
function withProviders<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
function runInProviders<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = composable();
return () => h("div");
result = fn();
return () => null;
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () => h(Child),
}),
const App = defineComponent({
setup() {
return () =>
h(StateProvider, { initialState }, () =>
h(VisibilityProvider, null, () => h(Inner)),
);
},
});
return { result };
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("VisibilityProvider — provide/inject", () => {
it("useVisibility() throws outside a VisibilityProvider", () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(() => useVisibility()).toThrow(
"useVisibility must be used within a VisibilityProvider",
describe("useVisibility", () => {
it("provides isVisible function", () => {
const ctx = runInProviders({}, () => useVisibility());
expect(typeof ctx.isVisible).toBe("function");
});
it("provides visibility context with stateModel", () => {
const ctx = runInProviders({ test: true }, () => useVisibility());
expect(ctx.ctx.stateModel).toEqual({ test: true });
});
});
describe("useIsVisible", () => {
it("returns true for undefined condition", () => {
const result = runInProviders({}, () => useIsVisible(undefined));
expect(result).toBe(true);
});
it("returns true for true condition", () => {
const result = runInProviders({}, () => useIsVisible(true));
expect(result).toBe(true);
});
it("returns false for false condition", () => {
const result = runInProviders({}, () => useIsVisible(false));
expect(result).toBe(false);
});
it("evaluates $state conditions against state", () => {
const trueResult = runInProviders({ isVisible: true }, () =>
useIsVisible({ $state: "/isVisible" }),
);
warn.mockRestore();
expect(trueResult).toBe(true);
const falseResult = runInProviders({ isVisible: false }, () =>
useIsVisible({ $state: "/isVisible" }),
);
expect(falseResult).toBe(false);
});
it("useVisibility() returns a context with isVisible and ctx", () => {
const { result } = withProviders(() => useVisibility());
expect(typeof result.isVisible).toBe("function");
expect(result.ctx).toBeDefined();
expect(result.ctx.value).toBeDefined();
});
});
describe("useIsVisible — state integration", () => {
it("undefined condition returns true", () => {
const { result } = withProviders(() => useVisibility());
expect(result.isVisible(undefined)).toBe(true);
});
it("{ $state: '/flag' } returns true when state flag is truthy", () => {
const { result } = withProviders(() => useVisibility(), { flag: true });
expect(result.isVisible({ $state: "/flag" })).toBe(true);
});
it("{ $state: '/flag' } returns false when state flag is falsy", () => {
const { result } = withProviders(() => useVisibility(), { flag: false });
expect(result.isVisible({ $state: "/flag" })).toBe(false);
});
it("ctx is a ComputedRef whose .value reflects current state", () => {
const { result } = withProviders(() => useVisibility(), { count: 3 });
expect(result.ctx.value.stateModel).toEqual({ count: 3 });
});
});
describe("useIsVisible — reactivity", () => {
it("returns a ComputedRef<boolean> that updates when state changes", () => {
let storeCtx!: ReturnType<typeof useStateStore>;
let isVisible!: ComputedRef<boolean>;
const Child = defineComponent({
setup() {
storeCtx = useStateStore();
isVisible = useIsVisible({ $state: "/flag" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { flag: false } } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () => h(Child),
}),
},
});
expect(isVisible.value).toBe(false);
storeCtx.set("/flag", true);
expect(isVisible.value).toBe(true);
it("evaluates equality conditions", () => {
const result = runInProviders({ count: 1 }, () =>
useIsVisible({ $state: "/count", eq: 1 }),
);
expect(result).toBe(true);
});
it("evaluates array conditions (implicit AND)", () => {
const result = runInProviders({ user: { isAdmin: true }, count: 5 }, () =>
useIsVisible([{ $state: "/user/isAdmin" }, { $state: "/count", eq: 5 }]),
);
expect(result).toBe(true);
});
});
+23 -31
View File
@@ -1,68 +1,60 @@
import {
computed,
defineComponent,
inject,
provide,
type ComputedRef,
inject,
computed,
type InjectionKey,
} from "vue";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useStateStore } from "./state.js";
/**
* Visibility context value
*/
export interface VisibilityContextValue {
/** Evaluate a visibility condition */
isVisible: (condition: VisibilityCondition | undefined) => boolean;
/** The underlying visibility context (reactive) */
ctx: ComputedRef<CoreVisibilityContext>;
ctx: CoreVisibilityContext;
}
const VISIBILITY_KEY = Symbol("json-render:visibility");
export const VisibilityKey: InjectionKey<VisibilityContextValue> = Symbol(
"json-render-visibility",
);
/**
* Provider for visibility evaluation
*/
export const VisibilityProvider = defineComponent({
name: "VisibilityProvider",
setup(_, { slots }) {
const { state } = useStateStore();
const storeCtx = useStateStore();
const ctx = computed<CoreVisibilityContext>(() => ({
stateModel: state.value,
const ctxRef = computed<CoreVisibilityContext>(() => ({
stateModel: storeCtx.state,
}));
const isVisible = (condition: VisibilityCondition | undefined): boolean =>
evaluateVisibility(condition, ctx.value);
const value: VisibilityContextValue = {
isVisible: (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, ctxRef.value),
get ctx() {
return ctxRef.value;
},
};
provide<VisibilityContextValue>(VISIBILITY_KEY, { isVisible, ctx });
provide(VisibilityKey, value);
return () => slots.default?.();
},
});
/**
* Composable to access visibility evaluation
*/
export function useVisibility(): VisibilityContextValue {
const ctx = inject<VisibilityContextValue>(VISIBILITY_KEY);
const ctx = inject(VisibilityKey);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
/**
* Composable to check if a condition is visible. Returns a reactive
* `ComputedRef<boolean>` so the result updates whenever state changes.
*/
export function useIsVisible(
condition: VisibilityCondition | undefined,
): ComputedRef<boolean> {
const { ctx } = useVisibility();
return computed(() => evaluateVisibility(condition, ctx.value));
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
-482
View File
@@ -1,482 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, nextTick, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { StateProvider, useStateStore } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { useFieldValidation } from "./composables/validation";
import { useBoundProp } from "./hooks";
import {
Renderer,
JSONUIProvider,
type ComponentRegistry,
type ComponentRenderProps,
} from "./renderer";
// =============================================================================
// Stub components
// =============================================================================
const Button = defineComponent({
name: "Button",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () =>
h(
"button",
{ "data-testid": "btn", onClick: () => props.emit("press") },
String((props.element as any).props?.label ?? ""),
);
},
});
const Text = defineComponent({
name: "Text",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
return () => {
const value = (props.element as any).props?.text;
return h(
"span",
{ "data-testid": "text" },
value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value),
);
};
},
});
const Stack = defineComponent({
name: "Stack",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(_props, { slots }) {
return () => h("div", { "data-testid": "stack" }, slots.default?.());
},
});
const InputField = defineComponent({
name: "InputField",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue, setBoundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
const hasValidation = !!(bindingPath && elProps.checks?.length);
const config = hasValidation ? { checks: elProps.checks ?? [] } : undefined;
const { errors } = useFieldValidation(bindingPath ?? "", config);
return () =>
h("div", null, [
elProps.label ? h("label", null, elProps.label) : null,
h("input", {
"data-testid": "input",
value: boundValue ?? "",
onInput: (e: Event) =>
setBoundValue((e.target as HTMLInputElement).value),
}),
errors.value.length > 0
? h("span", { "data-testid": "input-error" }, errors.value[0])
: null,
]);
},
});
const Select = defineComponent({
name: "Select",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: undefined },
},
setup(props) {
const elProps = (props.element as any).props ?? {};
const bindingPath = (props.bindings as Record<string, string>)?.value;
const [boundValue] = useBoundProp<string>(
elProps.value as string | undefined,
bindingPath,
);
return () => h("span", { "data-testid": "select-value" }, boundValue ?? "");
},
});
const registry: ComponentRegistry = {
Button: Button as unknown as Component,
Text: Text as unknown as Component,
Input: InputField as unknown as Component,
Select: Select as unknown as Component,
Stack: Stack as unknown as Component,
};
// State probe to read state from tests
const StateProbe = defineComponent({
name: "StateProbe",
setup() {
const { state } = useStateStore();
return () =>
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state.value));
},
});
function getState(wrapper: ReturnType<typeof mount>): Record<string, unknown> {
return JSON.parse(wrapper.find("[data-testid='state-probe']").text());
}
// =============================================================================
// Mount helper
// =============================================================================
function mountWithProviders(
spec: Spec,
opts: {
functions?: Record<string, (args: Record<string, unknown>) => unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<void> | void
>;
initialState?: Record<string, unknown>;
} = {},
) {
return mount(JSONUIProvider as Component, {
props: {
registry,
initialState: opts.initialState ?? spec.state ?? {},
functions: opts.functions,
handlers: opts.handlers,
} as any,
slots: {
default: () => [h(Renderer, { spec, registry }), h(StateProbe)],
},
});
}
// =============================================================================
// $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}`,
};
const wrapper = mountWithProviders(spec, { functions });
expect(wrapper.find("[data-testid='text']").text()).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: [],
},
},
};
const wrapper = mountWithProviders(spec);
expect(wrapper.find("[data-testid='text']").text()).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn();
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: [],
},
},
};
const wrapper = mountWithProviders(spec, { handlers: { loadCities } });
expect(loadCities).not.toHaveBeenCalled();
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(loadCities).toHaveBeenCalledTimes(1);
expect(loadCities).toHaveBeenCalledWith(
expect.objectContaining({ country: "US" }),
);
});
it("fires multiple action bindings on the same watch path", async () => {
const action1 = vi.fn();
const action2 = vi.fn();
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: [],
},
},
};
const wrapper = mountWithProviders(spec, {
handlers: { action1, action2 },
});
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
await nextTick();
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false, errors } when a required field is empty", async () => {
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: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true, errors: {} } when all fields pass validation", async () => {
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: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
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: [],
},
},
};
const wrapper = mountWithProviders(spec);
await wrapper.find("[data-testid='btn']").trigger("click");
await nextTick();
const state = getState(wrapper);
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
+328 -281
View File
@@ -1,347 +1,394 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { defineComponent, h, ref, type Component } from "vue";
import { mount } from "@vue/test-utils";
import { SPEC_DATA_PART_TYPE } from "@json-render/core";
import { StateProvider, useStateStore } from "./composables/state";
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp } from "vue";
import {
flatToTree,
buildSpecFromParts,
getTextFromParts,
useBoundProp,
useUIStream,
useJsonRenderMessage,
type DataPart,
} from "./hooks";
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/** Mount inside a StateProvider and capture a composable's result. */
function withStateProvider<T>(
composable: () => T,
initialState: Record<string, unknown> = {},
): { result: T } {
let result!: T;
const Child = defineComponent({
setup() {
result = composable();
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState } as any,
slots: { default: () => h(Child) },
});
return { result };
}
/** Create a simple ReadableStream from a string (for fetch mocks). */
function makeReadableStream(text: string): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
return new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode(text));
controller.close();
},
});
}
// ---------------------------------------------------------------------------
// flatToTree
// ---------------------------------------------------------------------------
} from "./hooks.js";
import { StateProvider, useStateBinding } from "./composables/state.js";
describe("flatToTree", () => {
it("converts flat elements into a Spec with root and children", () => {
it("converts array of elements to tree structure", () => {
const elements = [
{ key: "root", type: "Stack", props: {}, parentKey: undefined },
{ key: "container", type: "stack", props: {}, parentKey: null },
{
key: "child1",
type: "Text",
props: { content: "hello" },
parentKey: "root",
key: "text1",
type: "text",
props: { content: "Hello" },
parentKey: "container",
},
{
key: "text2",
type: "text",
props: { content: "World" },
parentKey: "container",
},
];
const spec = flatToTree(elements as any);
expect(spec.root).toBe("root");
expect(spec.elements["root"]?.children).toContain("child1");
expect(spec.elements["child1"]).toBeDefined();
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("handles a single root element with no children", () => {
it("builds parent-child relationships", () => {
const elements = [
{ key: "root", type: "Text", props: {}, parentKey: undefined },
{ key: "root", type: "stack", props: {}, parentKey: null },
{ key: "child1", type: "text", props: {}, parentKey: "root" },
{ key: "child2", type: "text", props: {}, parentKey: "root" },
];
const spec = flatToTree(elements as any);
expect(spec.root).toBe("root");
expect(spec.elements["root"]?.children).toEqual([]);
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 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 spec parts present", () => {
const parts: DataPart[] = [{ type: "text", text: "hello" }];
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("returns null for empty array", () => {
expect(buildSpecFromParts([])).toBeNull();
});
it("returns a Spec when a snapshot spec data part is present", () => {
const part: DataPart = {
type: SPEC_DATA_PART_TYPE,
data: {
type: "flat",
spec: {
root: "r",
elements: { r: { type: "Text", props: { content: "hi" } } },
},
},
};
const result = buildSpecFromParts([part]);
expect(result?.root).toBe("r");
expect(result?.elements["r"]).toBeDefined();
});
it("applies patch operations incrementally", () => {
const parts: DataPart[] = [
it("builds a spec from patch parts", () => {
const parts = [
{
type: SPEC_DATA_PART_TYPE,
type: "data-spec",
data: {
type: "patch",
patch: { op: "add", path: "/root", value: "myRoot" },
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 result = buildSpecFromParts(parts);
expect(result?.root).toBe("myRoot");
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("skips malformed data parts silently", () => {
const parts: DataPart[] = [
{ type: SPEC_DATA_PART_TYPE, data: { type: "unknown" } },
it("handles flat spec parts", () => {
const parts = [
{
type: "data-spec",
data: {
type: "flat",
spec: {
root: "card-1",
elements: {
"card-1": { type: "Card", props: {}, children: [] },
},
},
},
},
];
expect(buildSpecFromParts(parts)).toBeNull();
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("returns null from empty parts list", () => {
const spec = buildSpecFromParts([]);
expect(spec).toBeNull();
});
});
// ---------------------------------------------------------------------------
// getTextFromParts
// ---------------------------------------------------------------------------
describe("getTextFromParts", () => {
it("concatenates text parts with double newlines", () => {
const parts: DataPart[] = [
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
it("extracts text from text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("hello\n\nworld");
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: DataPart[] = [
{ type: "other", data: {} },
{ type: "text", text: "hi" },
const parts = [
{ type: "text", text: "Before" },
{ type: "data-spec", data: {} },
{ type: "tool-invocation", data: {} },
{ type: "text", text: "After" },
];
expect(getTextFromParts(parts)).toBe("hi");
expect(getTextFromParts(parts)).toBe("Before\n\nAfter");
});
it("returns empty string for empty array", () => {
expect(getTextFromParts([])).toBe("");
it("trims whitespace from text parts", () => {
const parts = [
{ type: "text", text: " Hello " },
{ type: "text", text: " World " },
];
expect(getTextFromParts(parts)).toBe("Hello\n\nWorld");
});
it("filters out empty/whitespace text parts", () => {
const parts: DataPart[] = [
{ type: "text", text: " " },
{ type: "text", text: "real" },
it("skips empty text parts", () => {
const parts = [
{ type: "text", text: "Hello" },
{ type: "text", text: " " },
{ type: "text", text: "World" },
];
expect(getTextFromParts(parts)).toBe("real");
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");
});
});
// ---------------------------------------------------------------------------
// useBoundProp
// ---------------------------------------------------------------------------
function runInStateProvider<T>(
initialState: Record<string, unknown>,
fn: () => T,
): T {
let result: T | undefined;
const Inner = defineComponent({
setup() {
result = fn();
return () => null;
},
});
const App = defineComponent({
setup() {
return () => h(StateProvider, { initialState }, () => h(Inner));
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
return result!;
}
describe("useBoundProp", () => {
it("returns the prop value and a no-op setter when no binding path", () => {
const { result } = withStateProvider(() =>
useBoundProp("hello", undefined),
it("returns the prop value as-is", () => {
const [value] = runInStateProvider({ name: "test" }, () =>
useBoundProp<string>("hello", undefined),
);
const [value, setValue] = result;
expect(value).toBe("hello");
expect(() => setValue("new")).not.toThrow();
});
it("returns undefined prop value when propValue is undefined", () => {
const { result } = withStateProvider(() =>
it("returns undefined when prop value is undefined", () => {
const [value] = runInStateProvider({}, () =>
useBoundProp<string>(undefined, undefined),
);
expect(result[0]).toBeUndefined();
expect(value).toBeUndefined();
});
it("setter writes to state at the binding path", () => {
let storeCtx!: ReturnType<typeof useStateStore>;
let setter!: (v: string) => void;
const Child = defineComponent({
setup() {
storeCtx = useStateStore();
const [, s] = useBoundProp<string>("Alice", "/name");
setter = s;
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: { name: "Alice" } } as any,
slots: { default: () => h(Child) },
});
setter("Bob");
expect(storeCtx.get("/name")).toBe("Bob");
});
});
// ---------------------------------------------------------------------------
// useUIStream
// ---------------------------------------------------------------------------
describe("useUIStream", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("send() sets isStreaming true then false after completion", async () => {
const patchLine =
JSON.stringify({ op: "add", path: "/root", value: "myRoot" }) +
"\n" +
JSON.stringify({
op: "add",
path: "/elements/myRoot",
value: { type: "Text", props: {} },
}) +
"\n";
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
body: makeReadableStream(patchLine),
}),
it("provides a setter function", () => {
const [, setValue] = runInStateProvider({ name: "test" }, () =>
useBoundProp<string>("hello", "/name"),
);
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
expect(streamResult.isStreaming.value).toBe(false);
const promise = streamResult.send("build me a UI");
expect(streamResult.isStreaming.value).toBe(true);
await promise;
expect(streamResult.isStreaming.value).toBe(false);
expect(streamResult.spec.value?.root).toBe("myRoot");
expect(typeof setValue).toBe("function");
});
});
it("error is set when fetch fails", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Network error")),
describe("useStateBinding", () => {
it("returns value from state and a setter", () => {
const [value, setValue] = runInStateProvider({ x: 42 }, () =>
useStateBinding<number>("/x"),
);
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
await streamResult.send("fail");
expect(streamResult.error.value?.message).toBe("Network error");
expect(value).toBe(42);
expect(typeof setValue).toBe("function");
});
it("clear() resets spec and error", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("oops")));
let streamResult!: ReturnType<typeof useUIStream>;
const Child = defineComponent({
setup() {
streamResult = useUIStream({ api: "/api/ui" });
return () => h("div");
},
});
mount(StateProvider as Component, {
props: { initialState: {} } as any,
slots: { default: () => h(Child) },
});
await streamResult.send("fail");
expect(streamResult.error.value).not.toBeNull();
streamResult.clear();
expect(streamResult.error.value).toBeNull();
expect(streamResult.spec.value).toBeNull();
});
});
// ---------------------------------------------------------------------------
// useJsonRenderMessage
// ---------------------------------------------------------------------------
describe("useJsonRenderMessage", () => {
it("returns null spec and false hasSpec when no spec parts", () => {
const { spec, hasSpec } = useJsonRenderMessage([
{ type: "text", text: "hi" },
]);
expect(spec.value).toBeNull();
expect(hasSpec.value).toBe(false);
});
it("extracts text from text parts", () => {
const { text } = useJsonRenderMessage([
{ type: "text", text: "hello" },
{ type: "text", text: "world" },
]);
expect(text.value).toBe("hello\n\nworld");
});
it("is reactive when passed a Ref<DataPart[]>", () => {
const parts = ref<DataPart[]>([]);
const { text, spec, hasSpec } = useJsonRenderMessage(parts);
expect(text.value).toBe("");
expect(spec.value).toBeNull();
parts.value = [{ type: "text", text: "hello" }];
expect(text.value).toBe("hello");
parts.value = [
...parts.value,
{
type: SPEC_DATA_PART_TYPE,
data: {
type: "flat",
spec: { root: "r", elements: { r: { type: "Text", props: {} } } },
},
},
];
expect(spec.value?.root).toBe("r");
expect(hasSpec.value).toBe(true);
it("returns undefined for missing path", () => {
const [value] = runInStateProvider({}, () =>
useStateBinding<string>("/missing"),
);
expect(value).toBeUndefined();
});
});
+79 -221
View File
@@ -1,13 +1,11 @@
import {
ref,
shallowRef,
computed,
onUnmounted,
isRef,
type Ref,
watch,
onBeforeUnmount,
type ComputedRef,
type Ref,
} from "vue";
import { useStateStore } from "./composables/state";
import type {
Spec,
UIElement,
@@ -25,6 +23,7 @@ import {
nestedToFlat,
SPEC_DATA_PART_TYPE,
} from "@json-render/core";
import { useStateStore } from "./composables/state.js";
/**
* Token usage metadata from AI generation
@@ -35,17 +34,11 @@ export interface TokenUsage {
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();
@@ -54,7 +47,6 @@ function parseLine(line: string): ParsedLine {
}
const parsed = JSON.parse(trimmed);
// Check for usage metadata
if (parsed.__meta === "usage") {
return {
type: "usage",
@@ -72,9 +64,6 @@ function parseLine(line: string): ParsedLine {
}
}
/**
* 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;
@@ -88,7 +77,7 @@ function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
const statePath = path.slice("/state".length); // e.g. "/posts"
const statePath = path.slice("/state".length);
setByPath(newSpec.state as Record<string, unknown>, statePath, value);
return;
}
@@ -116,9 +105,6 @@ function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
}
}
/**
* Remove a value at a spec path.
*/
function removeSpecValue(newSpec: Spec, path: string): void {
if (path === "/state") {
delete newSpec.state;
@@ -154,9 +140,6 @@ function removeSpecValue(newSpec: Spec, path: string): void {
}
}
/**
* 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;
@@ -167,10 +150,6 @@ function getSpecValue(spec: Spec, path: string): unknown {
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,
@@ -202,7 +181,6 @@ function applyPatch(spec: Spec, patch: JsonPatch): Spec {
break;
}
case "test": {
// test is a no-op for rendering purposes (validation only)
break;
}
}
@@ -210,69 +188,47 @@ function applyPatch(spec: Spec, patch: JsonPatch): Spec {
return newSpec;
}
/**
* Options for useUIStream
*/
// =============================================================================
// 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 */
spec: Ref<Spec | null>;
/** Whether currently streaming */
isStreaming: Ref<boolean>;
/** Error if any */
error: Ref<Error | null>;
/** Token usage from the last generation */
usage: Ref<TokenUsage | null>;
/** Raw JSONL lines received from the stream (JSON patch lines) */
rawLines: Ref<string[]>;
/** Send a prompt to generate UI */
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
/** Clear the current spec */
clear: () => void;
}
/**
* Composable for streaming UI generation
*/
export function useUIStream({
api,
onComplete,
onError,
}: UseUIStreamOptions): UseUIStreamReturn {
const spec = shallowRef<Spec | null>(null);
const spec = ref<Spec | null>(null);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
const usage = ref<TokenUsage | null>(null);
const rawLines = ref<string[]>([]);
const onCompleteRef = ref(onComplete);
const onErrorRef = ref(onError);
let abortController: AbortController | null = null;
const clear = () => {
spec.value = null;
error.value = null;
usage.value = null;
rawLines.value = [];
};
const send = async (
prompt: string,
context?: Record<string, unknown>,
): Promise<void> => {
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
@@ -281,7 +237,6 @@ export function useUIStream({
usage.value = null;
rawLines.value = [];
// Start with previous spec if provided, otherwise empty spec
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
@@ -293,16 +248,11 @@ export function useUIStream({
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
currentSpec,
}),
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();
@@ -312,7 +262,7 @@ export function useUIStream({
errorMessage = errorData.error;
}
} catch {
// Ignore JSON parsing errors, use default message
// Ignore JSON parsing errors
}
throw new Error(errorMessage);
}
@@ -331,7 +281,6 @@ export function useUIStream({
buffer += decoder.decode(value, { stream: true });
// Process complete lines
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
@@ -350,7 +299,6 @@ export function useUIStream({
}
}
// Process any remaining buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
@@ -365,21 +313,20 @@ export function useUIStream({
}
}
onCompleteRef.value?.(currentSpec);
onComplete?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
onErrorRef.value?.(resolvedError);
onError?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
// Cleanup on unmount
onUnmounted(() => {
onBeforeUnmount(() => {
abortController?.abort();
});
@@ -394,17 +341,14 @@ export function useUIStream({
};
}
/**
* 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.
*/
// =============================================================================
// flatToTree
// =============================================================================
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,
@@ -414,7 +358,6 @@ export function flatToTree(elements: FlatElement[]): Spec {
};
}
// Second pass: build parent-child relationships
for (const element of elements) {
if (element.parentKey) {
const parent = elementMap[element.parentKey];
@@ -433,62 +376,15 @@ export function flatToTree(elements: FlatElement[]): Spec {
}
// =============================================================================
// useBoundProp — Two-way binding helper for $bindState/$bindItem expressions
// buildSpecFromParts / getTextFromParts
// =============================================================================
/**
* Composable 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
* ```ts
* import { useBoundProp } from "@json-render/vue";
*
* const Input: ComponentFn<AppCatalog, "Input"> = ({ props, bindings }) => {
* const [value, setValue] = useBoundProp<string>(props.value as string, bindings?.value);
* return h("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 } = useStateStore();
return [
propValue,
(value: T) => {
if (bindingPath) set(bindingPath, value);
},
];
}
// =============================================================================
// 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;
}
/**
* Type guard that validates a data part payload looks like a valid
* SpecDataPart before we cast it.
*/
function isSpecDataPart(data: unknown): data is SpecDataPart {
if (typeof data !== "object" || data === null) return false;
const obj = data as Record<string, unknown>;
@@ -503,14 +399,6 @@ function isSpecDataPart(data: unknown): data is SpecDataPart {
}
}
/**
* Build a `Spec` by replaying all spec data parts from a message's
* parts array. Returns `null` if no spec data parts are present.
*
* Works with the AI SDK's `UIMessage.parts` array. Picks out parts whose
* `type` is `SPEC_DATA_PART_TYPE` and processes them based on the payload's
* `type` discriminator: `"patch"`, `"flat"`, or `"nested"`.
*/
export function buildSpecFromParts(parts: DataPart[]): Spec | null {
const spec: Spec = { root: "", elements: {} };
let hasSpec = false;
@@ -536,13 +424,6 @@ export function buildSpecFromParts(parts: DataPart[]): Spec | null {
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.
*/
export function getTextFromParts(parts: DataPart[]): string {
return parts
.filter(
@@ -555,81 +436,69 @@ export function getTextFromParts(parts: DataPart[]): string {
}
// =============================================================================
// useJsonRenderMessage — extract spec + text from message parts
// useJsonRenderMessage
// =============================================================================
/**
* Composable that extracts both the json-render spec and text content from a
* message's parts array. Accepts a plain `DataPart[]` or a `Ref<DataPart[]>`
* for reactive use in streaming scenarios.
*
* Returns `ComputedRef`s that recompute whenever `parts` changes.
*
* @example
* ```ts
* import { useJsonRenderMessage } from "@json-render/vue";
*
* const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
* ```
*/
export function useJsonRenderMessage(parts: DataPart[] | Ref<DataPart[]>): {
spec: ComputedRef<Spec | null>;
text: ComputedRef<string>;
hasSpec: ComputedRef<boolean>;
} {
const partsRef = isRef(parts) ? parts : ref(parts);
const spec = computed(() => buildSpecFromParts(partsRef.value));
const text = computed(() => getTextFromParts(partsRef.value));
const hasSpec = computed(
() =>
spec.value !== null && Object.keys(spec.value.elements || {}).length > 0,
export function useJsonRenderMessage(parts: Ref<DataPart[]>) {
const result = ref<{ spec: Spec | null; text: string }>({
spec: null,
text: "",
});
let prevParts: DataPart[] = [];
watch(
parts,
(newParts) => {
const partsChanged =
newParts !== prevParts &&
(newParts.length !== prevParts.length ||
newParts[newParts.length - 1] !== prevParts[prevParts.length - 1]);
if (partsChanged || prevParts.length === 0) {
prevParts = newParts;
result.value = {
spec: buildSpecFromParts(newParts),
text: getTextFromParts(newParts),
};
}
},
{ immediate: true, deep: true },
);
return { spec, text, hasSpec };
return {
spec: computed(() => result.value.spec),
text: computed(() => result.value.text),
hasSpec: computed(() => {
const s = result.value.spec;
return s !== null && Object.keys(s.elements || {}).length > 0;
}),
result,
};
}
// =============================================================================
// useChatUI — Chat + GenUI composable
// useChatUI
// =============================================================================
/**
* 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 */
messages: Ref<ChatMessage[]>;
/** Whether currently streaming an assistant response */
isStreaming: Ref<boolean>;
/** Error from the last request, if any */
error: Ref<Error | null>;
/** Send a user message */
send: (text: string) => Promise<void>;
/** Clear all messages and reset the conversation */
clear: () => void;
}
@@ -645,21 +514,6 @@ function generateChatId(): string {
return `msg-${Date.now()}-${chatMessageIdCounter}`;
}
/**
* Composable for chat + GenUI experiences.
*
* Manages a multi-turn conversation where each assistant message can contain
* both conversational text and a json-render UI spec. Sends the full message
* history to the API endpoint, reads the streamed response, and separates
* text lines from JSONL patch lines using `createMixedStreamParser`.
*
* @example
* ```ts
* const { messages, isStreaming, send, clear } = useChatUI({ api: "/api/chat" });
*
* await send("Compare weather in NYC and Tokyo");
* ```
*/
export function useChatUI({
api,
onComplete,
@@ -668,10 +522,6 @@ export function useChatUI({
const messages = ref<ChatMessage[]>([]);
const isStreaming = ref(false);
const error = ref<Error | null>(null);
const onCompleteRef = ref(onComplete);
const onErrorRef = ref(onError);
let abortController: AbortController | null = null;
const clear = () => {
@@ -682,7 +532,6 @@ export function useChatUI({
const send = async (text: string): Promise<void> => {
if (!text.trim()) return;
// Abort any existing request
abortController?.abort();
abortController = new AbortController();
@@ -701,21 +550,19 @@ export function useChatUI({
spec: null,
};
// Append user message and empty assistant placeholder
messages.value = [...messages.value, userMessage, assistantMessage];
isStreaming.value = true;
error.value = null;
// Build messages array for the API (full conversation history + new message).
// Vue refs are always current — no stale closure issue unlike React useRef.
const historyForApi = [
...messages.value
.filter((m) => m.id !== assistantId)
.map((m) => ({ role: m.role, content: m.text })),
{ role: "user" as const, content: text.trim() },
.map((m) => ({
role: m.role,
content: m.text,
})),
];
// Mutable state for accumulating the assistant response
let accumulatedText = "";
let currentSpec: Spec = { root: "", elements: {} };
let hasSpec = false;
@@ -750,7 +597,6 @@ export function useChatUI({
const decoder = new TextDecoder();
// Use createMixedStreamParser to classify lines
const parser = createMixedStreamParser({
onPatch(patch) {
hasSpec = true;
@@ -785,7 +631,6 @@ export function useChatUI({
}
parser.flush();
// Build final message for onComplete callback
const finalMessage: ChatMessage = {
id: assistantId,
role: "assistant",
@@ -798,25 +643,23 @@ export function useChatUI({
}
: null,
};
onCompleteRef.value?.(finalMessage);
onComplete?.(finalMessage);
} catch (err) {
if ((err as Error).name === "AbortError") {
return;
}
const resolvedError = err instanceof Error ? err : new Error(String(err));
error.value = resolvedError;
// Remove empty assistant message on error
messages.value = messages.value.filter(
(m) => m.id !== assistantId || m.text.length > 0,
);
onErrorRef.value?.(resolvedError);
onError?.(resolvedError);
} finally {
isStreaming.value = false;
}
};
// Cleanup on unmount
onUnmounted(() => {
onBeforeUnmount(() => {
abortController?.abort();
});
@@ -828,3 +671,18 @@ export function useChatUI({
clear,
};
}
// =============================================================================
// useBoundProp
// =============================================================================
export function useBoundProp<T>(
propValue: T | undefined,
bindingPath: string | undefined,
): [T | undefined, (value: T) => void] {
const { set } = useStateStore();
const setValue = (value: T) => {
if (bindingPath) set(bindingPath, value);
};
return [propValue, setValue];
}
+40 -33
View File
@@ -1,52 +1,61 @@
// Composables & Providers
// Composables – State
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
StateKey,
type StateContextValue,
type StateProviderProps,
} from "./composables/state";
} from "./composables/state.js";
// Composables – Visibility
export {
VisibilityProvider,
useVisibility,
useIsVisible,
VisibilityKey,
type VisibilityContextValue,
} from "./composables/visibility";
} from "./composables/visibility.js";
// Composables – Actions
export {
ActionProvider,
useActions,
useAction,
ActionKey,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./composables/actions";
} from "./composables/actions.js";
// Composables – Validation
export {
ValidationProvider,
useOptionalValidation,
useValidation,
useOptionalValidation,
useFieldValidation,
ValidationKey,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./composables/validation";
} from "./composables/validation.js";
// Composables – Repeat Scope
export {
RepeatScopeProvider,
useRepeatScope,
RepeatScopeKey,
type RepeatScopeValue,
} from "./composables/repeat-scope";
} from "./composables/repeat-scope.js";
// Schema
export { schema, type VueSchema, type VueSpec } from "./schema";
// Schema (Vue's spec format)
export { schema, type VueSchema, type VueSpec } from "./schema.js";
// Core types (re-exported for convenience)
export type { Spec, StateStore, ComputedFunction } from "@json-render/core";
export type { Spec, StateStore } from "@json-render/core";
export { createStateStore } from "@json-render/core";
// Catalog-aware types for Vue
@@ -60,40 +69,38 @@ export type {
Components,
ActionFn,
Actions,
} from "./catalog-types";
} from "./catalog-types.js";
// Hooks
// Renderer
export {
defineRegistry,
type DefineRegistryResult,
createRenderer,
type CreateRendererProps,
type ComponentMap,
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer.js";
// Hooks / composables (streaming, chat, spec utilities)
export {
useUIStream,
useChatUI,
useJsonRenderMessage,
useBoundProp,
flatToTree,
buildSpecFromParts,
getTextFromParts,
useJsonRenderMessage,
type TokenUsage,
type UseUIStreamOptions,
type UseUIStreamReturn,
type UseChatUIOptions,
type UseChatUIReturn,
type ChatMessage,
type DataPart,
type TokenUsage,
} from "./hooks";
// Renderer
export {
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Low-level
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
} from "./hooks.js";
+384
View File
@@ -0,0 +1,384 @@
import { describe, it, expect } from "vitest";
import { defineComponent, h, createApp, nextTick, type VNode } from "vue";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer.js";
import { useStateStore } from "./composables/state.js";
import { useActions } from "./composables/actions.js";
import { useBoundProp } from "./hooks.js";
import type { EventHandle } from "./catalog-types.js";
// =============================================================================
// Stub components
// =============================================================================
const Button = defineComponent({
name: "Button",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () =>
h(
"button",
{
"data-testid": "btn",
onClick: () => (props.emit as (e: string) => void)("press"),
},
(props.element as any).props.label,
);
},
});
const Text = defineComponent({
name: "Text",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () => {
const value = (props.element as any).props.text;
return h(
"span",
{ "data-testid": "text" },
value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value),
);
};
},
});
const Stack = defineComponent({
name: "Stack",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(_, { slots }) {
return () => h("div", { "data-testid": "stack" }, slots.default?.());
},
});
const Input = defineComponent({
name: "Input",
props: {
element: { type: Object, required: true },
emit: { type: Function, required: true },
on: { type: Function, required: true },
bindings: { type: Object, default: undefined },
loading: { type: Boolean, default: false },
},
setup(props) {
return () => {
const elProps = (props.element as any).props;
const [value, setValue] = useBoundProp<string>(
elProps.value as string | undefined,
(props.bindings as Record<string, string> | undefined)?.value,
);
return h("input", {
"data-testid": "input",
value: value ?? "",
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
});
};
},
});
const StateProbe = defineComponent({
name: "StateProbe",
setup() {
const { state } = useStateStore();
return () =>
h("pre", { "data-testid": "state-probe" }, JSON.stringify(state));
},
});
import type { ComponentRegistry } from "./renderer.js";
const registry: ComponentRegistry = { Button, Text, Stack, Input } as any;
// =============================================================================
// Helpers
// =============================================================================
function mountApp(spec: Spec, initialState?: Record<string, unknown>) {
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{ registry, initialState: initialState ?? spec.state ?? {} },
() => [h(Renderer, { spec, registry }), h(StateProbe)],
);
},
});
const app = createApp(App);
app.mount(container);
const getState = () => {
const probe = container.querySelector("[data-testid='state-probe']");
return JSON.parse(probe!.textContent!);
};
return { container, app, getState };
}
// =============================================================================
// Tests
// =============================================================================
describe("integration: basic rendering", () => {
it("renders a simple spec with text", async () => {
const spec: Spec = {
root: "main",
elements: {
main: { type: "Text", props: { text: "Hello World" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const text = container.querySelector("[data-testid='text']");
expect(text?.textContent).toBe("Hello World");
app.unmount();
});
it("renders nested elements", async () => {
const spec: Spec = {
root: "stack",
elements: {
stack: { type: "Stack", props: {}, children: ["t1", "t2"] },
t1: { type: "Text", props: { text: "First" } },
t2: { type: "Text", props: { text: "Second" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(2);
expect(texts[0]?.textContent).toBe("First");
expect(texts[1]?.textContent).toBe("Second");
app.unmount();
});
});
describe("integration: state binding", () => {
it("resolves $state expressions in props", async () => {
const spec: Spec = {
root: "main",
state: { greeting: "Hello from state" },
elements: {
main: { type: "Text", props: { text: { $state: "/greeting" } } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const text = container.querySelector("[data-testid='text']");
expect(text?.textContent).toBe("Hello from state");
app.unmount();
});
});
describe("integration: actions", () => {
it("executes setState via action context", async () => {
let storeRef: ReturnType<typeof useStateStore> | undefined;
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
const Probe = defineComponent({
name: "ActionProbe",
setup() {
storeRef = useStateStore();
const { execute } = useActions();
actionsRef = { execute };
return () => null;
},
});
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{ registry, initialState: { clicked: false } },
() => h(Probe),
);
},
});
const app = createApp(App);
app.mount(container);
await nextTick();
expect(storeRef!.get("/clicked")).toBe(false);
await actionsRef!.execute({
action: "setState",
params: { statePath: "/clicked", value: true },
});
expect(storeRef!.get("/clicked")).toBe(true);
app.unmount();
});
it("chains pushState and setState with $state resolution", async () => {
let storeRef: ReturnType<typeof useStateStore> | undefined;
let actionsRef: { execute: (b: any) => Promise<void> } | undefined;
const Probe = defineComponent({
name: "ActionProbe",
setup() {
storeRef = useStateStore();
const { execute } = useActions();
actionsRef = { execute };
return () => null;
},
});
const container = document.createElement("div");
const App = defineComponent({
setup() {
return () =>
h(
JSONUIProvider,
{
registry,
initialState: { items: ["initial"], observed: "not yet set" },
},
() => h(Probe),
);
},
});
const app = createApp(App);
app.mount(container);
await nextTick();
await actionsRef!.execute({
action: "pushState",
params: { statePath: "/items", value: "new-item" },
});
await actionsRef!.execute({
action: "setState",
params: {
statePath: "/observed",
value: { $state: "/items" },
},
});
expect(storeRef!.get("/items")).toEqual(["initial", "new-item"]);
expect(storeRef!.get("/observed")).toEqual(["initial", "new-item"]);
app.unmount();
});
});
describe("integration: visibility", () => {
it("hides elements when visible is false", async () => {
const spec: Spec = {
root: "stack",
state: { show: false },
elements: {
stack: { type: "Stack", props: {}, children: ["hidden", "visible"] },
hidden: {
type: "Text",
props: { text: "Hidden" },
visible: { $state: "/show" },
},
visible: { type: "Text", props: { text: "Visible" } },
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(1);
expect(texts[0]?.textContent).toBe("Visible");
app.unmount();
});
it("shows elements when visible condition is true", async () => {
const spec: Spec = {
root: "stack",
state: { show: true },
elements: {
stack: { type: "Stack", props: {}, children: ["conditional"] },
conditional: {
type: "Text",
props: { text: "Shown" },
visible: { $state: "/show" },
},
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(1);
expect(texts[0]?.textContent).toBe("Shown");
app.unmount();
});
});
describe("integration: repeat", () => {
it("renders repeated elements from state array", async () => {
const spec: Spec = {
root: "list",
state: { items: ["apple", "banana", "cherry"] },
elements: {
list: {
type: "Stack",
props: {},
repeat: { statePath: "/items" },
children: ["item"],
},
item: {
type: "Text",
props: { text: { $item: "." } },
},
},
};
const { container, app } = mountApp(spec);
await nextTick();
const texts = container.querySelectorAll("[data-testid='text']");
expect(texts.length).toBe(3);
app.unmount();
});
});
+65 -189
View File
@@ -1,200 +1,76 @@
import { describe, it, expect, vi } from "vitest";
import { defineComponent, h, type Component } from "vue";
import { mount } from "@vue/test-utils";
import type { Spec } from "@json-render/core";
import { StateProvider } from "./composables/state";
import { VisibilityProvider } from "./composables/visibility";
import { ActionProvider } from "./composables/actions";
import { ValidationProvider } from "./composables/validation";
import { Renderer, defineRegistry, type ComponentRegistry } from "./renderer";
// ---------------------------------------------------------------------------
// Minimal test catalog and registry
// ---------------------------------------------------------------------------
// defineRegistry ignores the catalog object at runtime — use any cast
const catalog = {} as any;
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => {
const p = props as Record<string, unknown>;
return h(
"div",
{ "data-type": "card", "data-title": String(p["title"] ?? "") },
[String(p["title"] ?? ""), children],
);
},
Button: ({ props, emit }) => {
const p = props as Record<string, unknown>;
return h(
"button",
{ "data-type": "button", onClick: () => emit("press") },
String(p["label"] ?? ""),
);
},
},
});
// ---------------------------------------------------------------------------
// Mount helper: wraps in the full provider chain required by ElementRenderer
// ---------------------------------------------------------------------------
function mountRenderer(
spec: Spec | null,
reg: ComponentRegistry = registry,
extraProps: Record<string, unknown> = {},
handlers: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {},
initialState: Record<string, unknown> = {},
) {
return mount(StateProvider as Component, {
props: { initialState } as any,
slots: {
default: () =>
h(VisibilityProvider as Component, null, {
default: () =>
h(ValidationProvider as Component, null, {
default: () =>
h(ActionProvider as Component, { handlers } as any, {
default: () =>
h(Renderer, { spec, registry: reg, ...extraProps }),
}),
}),
}),
},
});
}
// ---------------------------------------------------------------------------
// defineRegistry tests
// ---------------------------------------------------------------------------
describe("defineRegistry", () => {
it("wraps a render function and returns a Vue component", () => {
const result = defineRegistry(catalog, {
components: {
MyComp: () => h("span", null, "hello"),
},
});
expect(result.registry).toBeDefined();
expect(result.registry["MyComp"]).toBeDefined();
});
it("component receives resolved props from the spec element", () => {
const spec: Spec = {
root: "btn1",
elements: {
btn1: { type: "Button", props: { label: "Press me" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='button']").text()).toBe("Press me");
});
it("children is passed for container components", () => {
const spec: Spec = {
root: "card1",
elements: {
card1: {
type: "Card",
props: { title: "My Card" },
children: ["btn1"],
},
btn1: { type: "Button", props: { label: "Click" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='card']").exists()).toBe(true);
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
});
it("emit('press') fires the corresponding on.press action", async () => {
const handler = vi.fn().mockResolvedValue(undefined);
const spec: Spec = {
root: "btn1",
elements: {
btn1: {
type: "Button",
props: { label: "Click" },
on: { press: { action: "myAction" } },
},
},
};
const wrapper = mountRenderer(spec, registry, {}, { myAction: handler });
await wrapper.find("[data-type='button']").trigger("click");
expect(handler).toHaveBeenCalledOnce();
});
});
// ---------------------------------------------------------------------------
// Renderer tests
// ---------------------------------------------------------------------------
import { describe, it, expect } from "vitest";
import { h, createApp, defineComponent } from "vue";
import { Renderer, type ComponentRenderer } from "./renderer.js";
describe("Renderer", () => {
it("renders a single-element spec", () => {
const spec: Spec = {
root: "btn1",
elements: {
btn1: { type: "Button", props: { label: "Go" } },
},
};
const wrapper = mountRenderer(spec);
expect(wrapper.find("[data-type='button']").exists()).toBe(true);
expect(wrapper.find("[data-type='button']").text()).toBe("Go");
});
it("renders a nested spec (parent contains a child by key reference)", () => {
const spec: Spec = {
root: "card1",
elements: {
card1: {
type: "Card",
props: { title: "Root" },
children: ["btn1"],
},
btn1: { type: "Button", props: { label: "Action" } },
},
};
const wrapper = mountRenderer(spec);
const card = wrapper.find("[data-type='card']");
expect(card.exists()).toBe(true);
expect(card.find("[data-type='button']").exists()).toBe(true);
});
it("uses fallback component for unknown element types", () => {
const fallback = defineComponent({
it("renders nothing for null spec", () => {
let rendered = false;
const App = defineComponent({
setup() {
return () => h("span", { "data-type": "fallback" }, "fallback");
return () => {
rendered = true;
return h(Renderer, { spec: null, registry: {} });
};
},
});
const spec: Spec = {
root: "el1",
elements: {
el1: { type: "Unknown", props: {} },
},
};
const wrapper = mountRenderer(spec, registry, { fallback });
expect(wrapper.find("[data-type='fallback']").exists()).toBe(true);
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
expect(rendered).toBe(true);
expect(container.innerHTML).toBe("<!---->"); // Vue comment node for null
app.unmount();
});
it("passes loading prop through to registered components", () => {
let receivedLoading: boolean | undefined;
const { registry: testRegistry } = defineRegistry(catalog, {
components: {
Widget: ({ loading }) => {
receivedLoading = loading;
return h("div", null, "widget");
},
it("renders nothing for spec without root", () => {
const App = defineComponent({
setup() {
return () =>
h(Renderer, { spec: { root: "", elements: {} }, registry: {} });
},
});
const spec: Spec = {
root: "w1",
elements: { w1: { type: "Widget", props: {} } },
};
mountRenderer(spec, testRegistry, { loading: true });
expect(receivedLoading).toBe(true);
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
expect(container.innerHTML).toBe("<!---->");
app.unmount();
});
it("accepts loading prop", () => {
const App = defineComponent({
setup() {
return () => h(Renderer, { spec: null, registry: {}, loading: true });
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
});
it("accepts fallback prop", () => {
const Fallback = defineComponent({
setup() {
return () => h("div", null, "Unknown component");
},
});
const App = defineComponent({
setup() {
return () =>
h(Renderer, {
spec: null,
registry: {},
fallback: Fallback as unknown as ComponentRenderer,
});
},
});
const container = document.createElement("div");
const app = createApp(App);
app.mount(container);
app.unmount();
});
});
File diff suppressed because it is too large Load Diff
+1 -30
View File
@@ -9,43 +9,27 @@ import { defineSchema } from "@json-render/core";
*/
export const schema = defineSchema(
(s) => ({
// What the AI-generated SPEC looks like
spec: s.object({
/** Root element key */
root: s.string(),
/** Flat map of elements by key */
elements: s.record(
s.object({
/** Component type from catalog */
type: s.ref("catalog.components"),
/** Component props */
props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */
children: s.array(s.string()),
/** Visibility condition */
visible: s.any(),
}),
),
}),
// What the CATALOG must provide
catalog: s.object({
/** Component definitions */
components: s.map({
/** Zod schema for component props */
props: s.zod(),
/** Slots for this component. Use ['default'] for children, or named slots like ['header', 'footer'] */
slots: s.array(s.string()),
/** Description for AI generation hints */
description: s.string(),
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
/** Zod schema for action params */
params: s.zod(),
/** Description for AI generation hints */
description: s.string(),
}),
}),
@@ -70,23 +54,16 @@ export const schema = defineSchema(
{
name: "validateForm",
description:
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record<string, string[]> }.",
"Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
},
],
defaultRules: [
// Element integrity
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
// Field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
// State and data
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "statePath": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use { "$item": "field" } to read a field from the current item, and { "$index": true } for the current array index. For two-way binding to an item field use { "$bindItem": "completed" }. Do NOT hardcode individual elements for each array item.',
// Design quality
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
"Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
@@ -94,14 +71,8 @@ export const schema = defineSchema(
},
);
/**
* Type for the Vue schema
*/
export type VueSchema = typeof schema;
/**
* Infer the spec type from a catalog
*/
export type VueSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@internal/typescript-config/base.json",
"extends": "@internal/typescript-config/vue-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
+4786 -303
View File
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -3,12 +3,10 @@ import path from "path";
export default defineConfig({
resolve: {
// Deduplicate React and Vue so tests don't get two copies
// (pnpm strict resolution can cause packages to resolve different copies)
alias: {
react: path.resolve(__dirname, "node_modules/react"),
"react-dom": path.resolve(__dirname, "node_modules/react-dom"),
vue: path.resolve(__dirname, "packages/vue/node_modules/vue"),
vue: path.resolve(__dirname, "node_modules/vue"),
},
},
test: {