Compare commits

...
Author SHA1 Message Date
github-actions[bot] c2b397510e chore: version packages (#166)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-25 10:56:29 -06:00
Chris Tate 8506cfaa03 fix pkg name (#165)
* fix pkg name

* faster builds

* fixes

* Revert "fixes"

This reverts commit d0b43db972.

* Revert "faster builds"

This reverts commit 34a5190b07.
2026-02-25 10:50:59 -06:00
Chris Tate 9cef4e9142 prepare v0.10 (#164) 2026-02-25 10:27:25 -06:00
Chris Tate 3c11f19be4 vue improvements (#163)
* vue improvements

* fixes

* fixes

* fixes

* fix ci

* fix ci
2026-02-25 09:44:57 -06:00
Anthony Fu db3a8b41e9 feat: add Vue renderer (#162)
* feat: vue support

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
2026-02-25 08:21:11 -06:00
Chris Tate ea47b66dfc add dynamic forms support: computed values, watchers, cross-field validation (#156)
* add dynamic forms support: computed values, watchers, cross-field validation

- `$computed` and `$template` prop expressions for derived values and string interpolation
- Element-level `watch` field for cascading state dependencies
- Cross-field validators (`lessThan`, `greaterThan`, `equalTo`, `requiredIf`) with deep arg resolution
- `validateForm` built-in action for form-level validation

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

* fixes
2026-02-24 23:04:48 -06:00
Chris Tate cd82f969c8 xstate doc/test updates (#160) 2026-02-24 22:09:42 -06:00
David KhourshidandClaude Opus 4.6 6bcaaad57d feat: Add @xstate/store (atom) support (#157)
* feat: add @xstate/store integration using atoms

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add README for @json-render/xstate-store

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: rename xstateStoreStateStore to xstateStore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* README updates

* Keep naming convention

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:01:33 -06:00
github-actions[bot] 0b7d767cdd chore: version packages (#154)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 06:29:45 -06:00
Chris Tate b1036763d2 fixed: Install failure due to private dependency (#153)
* fix @internal/react-state import

* add changeset for @internal/react-state install fix

* docs: add v0.9.1 changelog entry
2026-02-24 06:25:34 -06:00
144 changed files with 12638 additions and 322 deletions
+3 -1
View File
@@ -13,7 +13,9 @@
"@json-render/codegen", "@json-render/codegen",
"@json-render/zustand", "@json-render/zustand",
"@json-render/redux", "@json-render/redux",
"@json-render/jotai" "@json-render/jotai",
"@json-render/vue",
"@json-render/xstate"
] ]
], ],
"linked": [], "linked": [],
+50 -5
View File
@@ -5,15 +5,18 @@
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output. Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
```bash ```bash
# for React
npm install @json-render/core @json-render/react npm install @json-render/core @json-render/react
# pre-built shadcn/ui components # for React with pre-built shadcn/ui components
npm install @json-render/shadcn npm install @json-render/shadcn
# or for mobile # or for React Native
npm install @json-render/core @json-render/react-native npm install @json-render/core @json-render/react-native
# or for video # or for video
npm install @json-render/core @json-render/remotion npm install @json-render/core @json-render/remotion
# or for PDF documents # or for PDF documents
npm install @json-render/core @json-render/react-pdf npm install @json-render/core @json-render/react-pdf
# or for Vue
npm install @json-render/core @json-render/vue
``` ```
## Why json-render? ## Why json-render?
@@ -23,7 +26,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
- **Guardrailed** - AI can only use components in your catalog - **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time - **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds - **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog - **Cross-Platform** - React, Vue (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use - **Batteries Included** - 36 pre-built shadcn/ui components ready to use
## Quick Start ## Quick Start
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
|---------|-------------| |---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities | | `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks | | `@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/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/react-native` | React Native renderer with standard mobile components | | `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/remotion` | Remotion video renderer, timeline schema | | `@json-render/remotion` | Remotion video renderer, timeline schema |
@@ -117,6 +121,7 @@ function Dashboard({ spec }) {
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` | | `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` | | `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` | | `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
## Renderers ## Renderers
@@ -148,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} /> <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) ### shadcn/ui (Web)
```tsx ```tsx
@@ -317,10 +342,12 @@ Any prop value can be data-driven using expressions:
} }
``` ```
Two expression forms: Expression forms:
- **`{ "$state": "/state/key" }`** - reads a value from the state model - **`{ "$state": "/state/key" }`** - reads a value from the state model
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch - **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
### Actions ### Actions
@@ -336,6 +363,22 @@ Components can trigger actions, including the built-in `setState` action:
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions. The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
### State Watchers
React to state changes by triggering actions:
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada", "UK"] },
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
--- ---
## Demo ## Demo
@@ -351,6 +394,8 @@ pnpm dev
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard - http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example - http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat` - Chat Example: run `pnpm dev` in `examples/chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native` - React Native example: run `npx expo start` in `examples/react-native`
## How It Works ## How It Works
+18
View File
@@ -1,5 +1,23 @@
# web # web
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
@@ -109,6 +109,27 @@ const { registry } = defineRegistry(catalog, {
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>; type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
``` ```
### JSONUIProvider
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
| Prop | Type | Description |
|------|------|-------------|
| `functions` | `Record<string, ComputedFunction>` | Named functions for `$computed` expressions in props |
```tsx
<JSONUIProvider
spec={spec}
catalog={catalog}
handlers={{ submit: async () => { /* ... */ } }}
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
The `functions` prop is also available on `createRenderer`.
### Component Props (via defineRegistry) ### Component Props (via defineRegistry)
```tsx ```tsx
@@ -237,6 +258,15 @@ const {
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`. `ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
### useOptionalValidation
Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
```typescript
const validation = useOptionalValidation();
// ValidationContextValue | null
```
### useBoundProp ### useBoundProp
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path. Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
+246
View File
@@ -0,0 +1,246 @@
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 |
+123
View File
@@ -5,6 +5,129 @@ export const metadata = pageMetadata("docs/changelog")
Notable changes and updates to json-render. Notable changes and updates to json-render.
## v0.10.0
February 2026
### New: `@json-render/vue`
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
```bash
npm install @json-render/core @json-render/vue
```
```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),
},
});
```
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
See the [Vue API reference](/docs/api/vue) for details.
### New: `@json-render/xstate`
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
```bash
npm install @json-render/xstate @xstate/store
```
```typescript
import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@json-render/xstate";
const atom = createAtom({ count: 0 });
const store = xstateStoreStateStore({ atom });
```
Requires `@xstate/store` v3+.
### New: `$computed` and `$template` Expressions
Two new prop expression types for dynamic values:
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
### New: State Watchers
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
### New: Cross-Field Validation
New built-in validation functions for cross-field comparisons:
- `equalTo` -- alias for `matches` with clearer semantics
- `lessThan` -- value must be less than another field
- `greaterThan` -- value must be greater than another field
- `requiredIf` -- required only when a condition field is truthy
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
### New: `validateForm` Action
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
```json
{
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
}
}
```
### Improved: shadcn/ui Validation
All form components now support `checks` and `validateOn` props:
- Checkbox, Radio, Switch added validation support
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
### New Examples
- **Vue example** -- standalone Vue 3 app with custom components
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
---
## v0.9.1
February 2026
### Fixed: Install failure due to private dependency
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
---
## v0.9.0 ## v0.9.0
February 2026 February 2026
@@ -0,0 +1,119 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
The condition uses the same [visibility](/docs/visibility) expression format. The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference ## Quick Reference
<div className="my-6 overflow-x-auto"> <div className="my-6 overflow-x-auto">
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format.
<td><code>{'{ "$bindItem": "field" }'}</code></td> <td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td> <td>Form components inside repeat</td>
</tr> </tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
+14 -9
View File
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" /> <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 ## For React UI with shadcn/ui
Pre-built components for fast prototyping and production use: Pre-built components for fast prototyping and production use:
@@ -35,17 +47,10 @@ If you want to wire json-render to an existing state management library instead
<PackageInstall packages="@json-render/jotai" /> <PackageInstall packages="@json-render/jotai" />
<PackageInstall packages="@json-render/xstate" />
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage. 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 ## For AI Integration
To use json-render with AI models, you'll also need the Vercel AI SDK: To use json-render with AI models, you'll also need the Vercel AI SDK:
+114 -7
View File
@@ -11,11 +11,18 @@ json-render includes common validation functions:
- `required` — Value must be non-empty - `required` — Value must be non-empty
- `email` — Valid email format - `email` — Valid email format
- `minLength` — Minimum string length - `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length - `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern - `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value - `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value - `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
## Using Validation in JSON ## Using Validation in JSON
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation. See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing ## Validation Timing
Control when validation runs with `validateOn`: Control when validation runs with `validateOn`:
- `change` — Validate on every input change - `change` — Validate on every input change
- `blur` — Validate when field loses focus - `blur` — Validate when field loses focus (default for Input, Textarea)
- `submit` — Validate only on form submission - `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next ## Next
Learn about [generation modes](/docs/generation-modes). - [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
+150
View File
@@ -0,0 +1,150 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
+13
View File
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
{ title: "Schemas", href: "/docs/schemas" }, { title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" }, { title: "Catalog", href: "/docs/catalog" },
{ title: "Data Binding", href: "/docs/data-binding" }, { title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" }, { title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" }, { title: "Validation", href: "/docs/validation" },
], ],
}, },
@@ -67,6 +69,16 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion", href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true, 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,
},
], ],
}, },
{ {
@@ -95,6 +107,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" }, { title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" }, { title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" }, { title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" }, { title: "@json-render/codegen", href: "/docs/api/codegen" },
], ],
}, },
+3
View File
@@ -23,7 +23,9 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/streaming": "Streaming", "docs/streaming": "Streaming",
"docs/validation": "Validation", "docs/validation": "Validation",
"docs/data-binding": "Data Binding", "docs/data-binding": "Data Binding",
"docs/computed-values": "Computed Values",
"docs/visibility": "Visibility", "docs/visibility": "Visibility",
"docs/watchers": "Watchers",
"docs/generation-modes": "Generation Modes", "docs/generation-modes": "Generation Modes",
"docs/code-export": "Code Export", "docs/code-export": "Code Export",
"docs/custom-schema": "Custom Schema & Renderer", "docs/custom-schema": "Custom Schema & Renderer",
@@ -38,6 +40,7 @@ export const PAGE_TITLES: Record<string, string> = {
// API references // API references
"docs/api/core": "@json-render/core API", "docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react 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-pdf": "@json-render/react-pdf API",
"docs/api/react-native": "@json-render/react-native API", "docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API", "docs/api/codegen": "@json-render/codegen API",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
+18
View File
@@ -1,5 +1,23 @@
# example-chat # example-chat
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-chat", "name": "example-chat",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+18
View File
@@ -1,5 +1,23 @@
# example-dashboard # example-dashboard
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-dashboard", "name": "example-dashboard",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+18
View File
@@ -1,5 +1,23 @@
# example-no-ai # example-no-ai
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+40 -22
View File
@@ -2,33 +2,31 @@
import { useState, useEffect, useCallback, type ReactNode } from "react"; import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion"; import ConfettiExplosion from "react-confetti-explosion";
import { import { JSONUIProvider, Renderer } from "@json-render/react";
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core"; import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry"; import {
registry,
actionHandlers,
computedFunctions,
onConfetti,
} from "@/lib/render/registry";
import { examples } from "@/lib/examples"; import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode { function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return ( return (
<StateProvider initialState={spec.state ?? {}}> <JSONUIProvider
<VisibilityProvider> registry={registry}
<ActionProvider handlers={actionHandlers}> initialState={spec.state ?? {}}
<ValidationProvider> handlers={actionHandlers}
functions={computedFunctions}
>
<Renderer spec={spec} registry={registry} /> <Renderer spec={spec} registry={registry} />
</ValidationProvider> </JSONUIProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
); );
} }
export default function Page() { export default function Page() {
const [selectedIndex] = useState(0); const [selectedIndex, setSelectedIndex] = useState(0);
const selected = examples[selectedIndex]!; const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0); const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false); const [confettiActive, setConfettiActive] = useState(false);
@@ -41,11 +39,27 @@ export default function Page() {
useEffect(() => onConfetti(fireConfetti), [fireConfetti]); useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return ( return (
<div className="h-screen flex items-center justify-center bg-muted/30"> <div className="h-screen flex flex-col bg-muted/30">
<div {/* Example selector */}
className="relative bg-background border rounded-lg shadow-sm" <nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
style={{ width: 960, height: 1080 }} {examples.map((ex, i) => (
<button
key={ex.name}
onClick={() => setSelectedIndex(i)}
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
i === selectedIndex
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
> >
{ex.name}
</button>
))}
</nav>
{/* Render area */}
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
{confettiActive && ( {confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion <ConfettiExplosion
@@ -63,10 +77,14 @@ export default function Page() {
/> />
</div> </div>
)} )}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10"> <div className="p-6 relative z-10">
<p className="text-xs text-muted-foreground mb-4">
{selected.description}
</p>
<SpecRenderer key={selectedIndex} spec={selected.spec} /> <SpecRenderer key={selectedIndex} spec={selected.spec} />
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }
+349
View File
@@ -508,4 +508,353 @@ export const examples: Example[] = [
}, },
}, },
}, },
// =========================================================================
// Advanced: Registration form with cross-field validation & $template
// =========================================================================
{
name: "Registration Form",
description:
"Cross-field validation, $template preview, and validateForm action",
spec: {
root: "card",
state: {
form: {
name: "",
email: "",
password: "",
confirmPassword: "",
accountType: "personal",
company: "",
},
result: null,
},
elements: {
card: {
type: "Card",
props: {
title: "Create Account",
description: "Fill out the form below to register",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"preview",
"sep0",
"nameInput",
"emailInput",
"passwordInput",
"confirmInput",
"sep1",
"accountTypeRadio",
"companyInput",
"sep2",
"actions",
"statusText",
],
},
// $template live preview
preview: {
type: "Text",
props: {
text: {
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
},
variant: "muted",
},
visible: { $state: "/form/name", neq: "" },
children: [],
},
sep0: { type: "Separator", props: { orientation: null }, children: [] },
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Jane Doe",
value: { $bindState: "/form/name" },
checks: [
{ type: "required", message: "Name is required" },
{
type: "minLength",
args: { min: 2 },
message: "Name must be at least 2 characters",
},
],
validateOn: "blur",
},
children: [],
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "jane@example.com",
value: { $bindState: "/form/email" },
checks: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email address" },
],
validateOn: "blur",
},
children: [],
},
passwordInput: {
type: "Input",
props: {
label: "Password",
name: "password",
type: "password",
placeholder: "At least 8 characters",
value: { $bindState: "/form/password" },
checks: [
{ type: "required", message: "Password is required" },
{
type: "minLength",
args: { min: 8 },
message: "Password must be at least 8 characters",
},
],
validateOn: "blur",
},
children: [],
},
confirmInput: {
type: "Input",
props: {
label: "Confirm Password",
name: "confirmPassword",
type: "password",
placeholder: "Re-enter your password",
value: { $bindState: "/form/confirmPassword" },
checks: [
{ type: "required", message: "Please confirm your password" },
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
],
validateOn: "blur",
},
children: [],
},
sep1: { type: "Separator", props: { orientation: null }, children: [] },
accountTypeRadio: {
type: "Radio",
props: {
label: "Account Type",
name: "accountType",
options: ["personal", "business"],
value: { $bindState: "/form/accountType" },
checks: null,
validateOn: null,
},
children: [],
},
companyInput: {
type: "Input",
props: {
label: "Company Name",
name: "company",
type: "text",
placeholder: "Acme Inc.",
value: { $bindState: "/form/company" },
checks: [
{
type: "requiredIf",
args: { field: { $state: "/form/accountType" } },
message: "Company name is required for business accounts",
},
],
validateOn: "blur",
},
visible: { $state: "/form/accountType", eq: "business" },
children: [],
},
sep2: { type: "Separator", props: { orientation: null }, children: [] },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["submitBtn"],
},
submitBtn: {
type: "Button",
props: { label: "Register", variant: "primary", disabled: null },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
// Validation result
statusText: {
type: "Alert",
props: {
title: "Validation Result",
message: {
$cond: { $state: "/result/valid", eq: true },
$then: "All fields are valid -- ready to submit!",
$else: "Please fix the errors above before submitting.",
},
type: {
$cond: { $state: "/result/valid", eq: true },
$then: "success",
$else: "error",
},
},
visible: { $state: "/result", neq: null },
children: [],
},
},
},
},
// =========================================================================
// Advanced: Cascading selects with watchers & $computed
// =========================================================================
{
name: "Cascading Selects",
description:
"Watchers reset dependent fields, $computed derives display values",
spec: {
root: "card",
state: {
form: { country: "", city: "" },
availableCities: [],
},
elements: {
card: {
type: "Card",
props: {
title: "Shipping Address",
description: "Select your country to load available cities",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"countrySelect",
"citySelect",
"sep",
"addressPreview",
"templatePreview",
],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK", "Germany", "Japan"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
watch: {
"/form/country": [
{
action: "setState",
params: {
statePath: "/availableCities",
value: {
$computed: "citiesForCountry",
args: { country: { $state: "/form/country" } },
},
},
},
{
action: "setState",
params: { statePath: "/form/city", value: "" },
},
],
},
children: [],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: { $state: "/availableCities" },
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
sep: { type: "Separator", props: { orientation: null }, children: [] },
// $computed formatted address
addressPreview: {
type: "Heading",
props: {
text: {
$computed: "formatAddress",
args: {
city: { $state: "/form/city" },
country: { $state: "/form/country" },
},
},
level: "h3",
},
children: [],
},
// $template string interpolation
templatePreview: {
type: "Text",
props: {
text: {
$template:
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
},
variant: "muted",
},
visible: { $state: "/form/country", neq: "" },
children: [],
},
},
},
},
]; ];
+9
View File
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
description: "Fire confetti", description: "Fire confetti",
}, },
}, },
functions: {
formatAddress: {
description:
"Formats country and city into a single address string like 'City, Country'",
},
citiesForCountry: {
description: "Returns an array of city names for the given country code",
},
},
}); });
+27 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { defineRegistry } from "@json-render/react"; import { defineRegistry } from "@json-render/react";
import type { ComputedFunction } from "@json-render/core";
import { shadcnComponents } from "@json-render/shadcn"; import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog"; import { catalog } from "./catalog";
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
}; };
} }
const cityData: Record<string, string[]> = {
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
};
export const { registry } = defineRegistry(catalog, { export const { registry } = defineRegistry(catalog, {
components: { components: {
...shadcnComponents, ...shadcnComponents,
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
}, },
}); });
export const actionHandlers: Record<string, () => void> = { export const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
confetti: () => confettiListener?.(), confetti: () => confettiListener?.(),
}; };
export const computedFunctions: Record<string, ComputedFunction> = {
formatAddress: (args) => {
const city = (args.city as string) ?? "";
const country = (args.country as string) ?? "";
if (!city && !country) return "No location selected";
if (!city) return country;
return `${city}, ${country}`;
},
citiesForCountry: (args) => {
const country = (args.country as string) ?? "";
return cityData[country] ?? [];
},
};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-no-ai", "name": "example-no-ai",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-react-native # example-react-native
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react-native@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-native@0.9.1
- @json-render/core@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-native", "name": "example-react-native",
"version": "0.1.1", "version": "0.1.3",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-react-pdf # example-react-pdf
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react-pdf@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-pdf@0.9.1
- @json-render/core@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-pdf", "name": "example-react-pdf",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+15
View File
@@ -1,5 +1,20 @@
# example-remotion # example-remotion
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/remotion@0.10.0
## 0.1.2
### Patch Changes
- @json-render/core@0.9.1
- @json-render/remotion@0.9.1
## 0.1.1 ## 0.1.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-remotion", "name": "example-remotion",
"version": "0.1.1", "version": "0.1.3",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -1,5 +1,21 @@
# com.example.json-render-demo # com.example.json-render-demo
## 0.0.4
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2 ## 0.0.2
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "com.example.json-render-demo", "name": "com.example.json-render-demo",
"version": "0.0.2", "version": "0.0.4",
"description": "Test", "description": "Test",
"private": true, "private": true,
"license": "~~proprietary~~", "license": "~~proprietary~~",
@@ -1,5 +1,21 @@
# com.example.json-render-fullpage-demo # com.example.json-render-fullpage-demo
## 0.0.4
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
## 0.0.3
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
## 0.0.2 ## 0.0.2
### Patch Changes ### Patch Changes
@@ -1,6 +1,6 @@
{ {
"name": "com.example.json-render-fullpage-demo", "name": "com.example.json-render-fullpage-demo",
"version": "0.0.2", "version": "0.0.4",
"description": "Full-page Stripe App example (alpha)", "description": "Full-page Stripe App example (alpha)",
"private": true, "private": true,
"license": "~~proprietary~~", "license": "~~proprietary~~",
+10
View File
@@ -0,0 +1,10 @@
# vite-renderers
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/vue@0.10.0
+31
View File
@@ -0,0 +1,31 @@
<!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
@@ -0,0 +1,27 @@
{
"name": "vite-renderers",
"version": "0.1.1",
"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
@@ -0,0 +1,30 @@
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
@@ -0,0 +1,51 @@
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>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/react/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,15 @@
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;
}
@@ -0,0 +1,155 @@
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>
),
};
@@ -0,0 +1,99 @@
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",
},
},
};
@@ -0,0 +1,50 @@
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" }),
);
},
};
}
@@ -0,0 +1,248 @@
/* ---- 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
@@ -0,0 +1,126 @@
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
@@ -0,0 +1,19 @@
<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>
@@ -0,0 +1,25 @@
<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>
@@ -0,0 +1,5 @@
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,166 @@
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
@@ -0,0 +1,17 @@
{
"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
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [vue(), react({ include: /\.tsx$/ })],
});
+9
View File
@@ -0,0 +1,9 @@
# example-vue
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/vue@0.10.0
+26
View File
@@ -0,0 +1,26 @@
<!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
@@ -0,0 +1,22 @@
{
"name": "example-vue",
"version": "0.1.1",
"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
@@ -0,0 +1,14 @@
<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
@@ -0,0 +1,68 @@
<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
@@ -0,0 +1,92 @@
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
@@ -0,0 +1,213 @@
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
@@ -0,0 +1,138 @@
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
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
+17
View File
@@ -0,0 +1,17 @@
{
"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
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"scripts": { "scripts": {
"build": "turbo run build", "build": "turbo run build",
"dev": "turbo run dev --concurrency 15", "dev": "turbo run dev --concurrency 20",
"lint": "turbo run lint", "lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx}\"", "format": "prettier --write \"**/*.{ts,tsx}\"",
"type-check": "turbo run check-types", "type-check": "turbo run check-types",
+13
View File
@@ -1,5 +1,18 @@
# @json-render/codegen # @json-render/codegen
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/codegen", "name": "@json-render/codegen",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees", "description": "Utilities for generating code from json-render UI trees",
"keywords": [ "keywords": [
+73
View File
@@ -1,5 +1,78 @@
# @json-render/core # @json-render/core
## 0.10.0
### Minor Changes
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
### New: `@json-render/vue` Package
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
- `defineRegistry` — create type-safe component registries from catalogs
- `Renderer` — render specs as Vue component trees
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
- Streaming: `useUIStream`, `useChatUI`
- External store support via `StateStore` interface
### New: `@json-render/xstate` Package
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
- Requires `@xstate/store` v3+
### New: `$computed` Expressions
Call registered functions from prop expressions:
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
- `ComputedFunction` type exported from `@json-render/core`
### New: `$template` Expressions
Interpolate state values into strings:
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
- Missing paths resolve to empty string
### New: State Watchers
React to state changes by triggering actions:
- `watch` field on elements maps state paths to action bindings
- Fires when watched values change (not on initial render)
- Supports cascading dependencies (e.g. country → city loading)
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
- Spec validator detects and auto-fixes `watch` placed inside props
### New: Cross-Field Validation Functions
New built-in validation functions for cross-field comparisons:
- `equalTo` — alias for `matches` with clearer semantics
- `lessThan` — value must be less than another field (numbers, strings, coerced)
- `greaterThan` — value must be greater than another field
- `requiredIf` — required only when a condition field is truthy
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
### New: `validateForm` Action (React)
Built-in action that validates all registered form fields at once:
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
- Default state path: `/formValidation` (configurable via `statePath` param)
- Added to React schema's built-in actions list
### Improved: shadcn/ui Validation
All form components now support validation:
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
- Shared validation schemas reduce catalog definition duplication
### Improved: React Provider Tree
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
## 0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+114
View File
@@ -188,6 +188,22 @@ Schema options:
| `resolvePropValue(value, ctx)` | Resolve a single prop expression | | `resolvePropValue(value, ctx)` | Resolve a single prop expression |
| `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element | | `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element |
| `PropExpression<T>` | Type for prop values that may contain expressions | | `PropExpression<T>` | Type for prop values that may contain expressions |
| `ComputedFunction` | Function signature for `$computed` expressions |
| `PropResolutionContext` | Context for resolving props (includes `functions` for `$computed`) |
### Validation
| Export | Purpose |
|--------|---------|
| `check.required()` | Required validation helper |
| `check.email()` | Email validation helper |
| `check.matches(path)` | Cross-field match helper |
| `check.equalTo(path)` | Cross-field equality helper |
| `check.lessThan(path)` | Cross-field less-than helper |
| `check.greaterThan(path)` | Cross-field greater-than helper |
| `check.requiredIf(path)` | Conditional required helper |
| `builtInValidationFunctions` | All built-in validation functions |
| `runValidationCheck()` | Run a single validation check |
### User Prompt ### User Prompt
@@ -285,6 +301,7 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j
| `Spec` | Base spec type | | `Spec` | Base spec type |
| `Catalog` | Catalog type | | `Catalog` | Catalog type |
| `BuiltInAction` | Built-in action type (`name` + `description`) | | `BuiltInAction` | Built-in action type (`name` + `description`) |
| `ComputedFunction` | Function signature for `$computed` expressions |
| `VisibilityCondition` | Visibility condition type (used by `$cond`) | | `VisibilityCondition` | Visibility condition type (used by `$cond`) |
| `VisibilityContext` | Context for evaluating visibility and prop expressions | | `VisibilityContext` | Context for evaluating visibility and prop expressions |
| `SpecStreamLine` | Single patch operation | | `SpecStreamLine` | Single patch operation |
@@ -372,6 +389,44 @@ Get the current array index inside a repeat:
`$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path). `$index` uses `true` as a sentinel flag because the index is a scalar value with no sub-path to navigate (unlike `$item` which needs a path).
### Template (`$template`)
Interpolate state values into strings using `${/path}` syntax:
```json
{
"label": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
}
```
Missing paths resolve to an empty string.
### Computed (`$computed`)
Call a registered function with resolved arguments:
```json
{
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
}
```
Functions are registered in the catalog and provided at runtime via the `functions` prop on the renderer.
```typescript
import type { ComputedFunction } from "@json-render/core";
const functions: Record<string, ComputedFunction> = {
fullName: (args) => `${args.first} ${args.last}`,
};
```
### API ### API
```typescript ```typescript
@@ -466,6 +521,65 @@ console.log(formatSpecIssues(issues));
const fixed = autoFixSpec(spec); const fixed = autoFixSpec(spec);
``` ```
## State Watchers
Elements can declare a `watch` field to trigger actions when state values change. `watch` is a top-level field on the element (sibling of `type`, `props`, `children`), not inside `props`.
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
Watchers only fire on value changes, not on initial render. Multiple action bindings per path execute sequentially.
## Validation
### Built-in Validation Functions
| Function | Description | Args |
|----------|-------------|------|
| `required` | Value must not be empty | — |
| `email` | Must be a valid email | — |
| `url` | Must be a valid URL | — |
| `numeric` | Must be a number | — |
| `minLength` | Minimum string length | `{ min: number }` |
| `maxLength` | Maximum string length | `{ max: number }` |
| `min` | Minimum numeric value | `{ min: number }` |
| `max` | Maximum numeric value | `{ max: number }` |
| `pattern` | Must match regex | `{ pattern: string }` |
| `matches` | Must equal another field | `{ other: { $state: "/path" } }` |
| `equalTo` | Alias for matches | `{ other: { $state: "/path" } }` |
| `lessThan` | Must be less than another field | `{ other: { $state: "/path" } }` |
| `greaterThan` | Must be greater than another field | `{ other: { $state: "/path" } }` |
| `requiredIf` | Required when condition is truthy | `{ field: { $state: "/path" } }` |
### TypeScript Helpers
```typescript
import { check } from "@json-render/core";
check.required("Field is required");
check.email("Invalid email");
check.matches("/form/password", "Passwords must match");
check.equalTo("/form/password", "Passwords must match");
check.lessThan("/form/endDate", "Must be before end date");
check.greaterThan("/form/startDate", "Must be after start date");
check.requiredIf("/form/enableNotifications", "Required when notifications enabled");
```
## Custom Schemas ## Custom Schemas
json-render supports completely different spec formats for different renderers: json-render supports completely different spec formats for different renderers:
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/core", "name": "@json-render/core",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.", "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [ "keywords": [
+5 -1
View File
@@ -72,7 +72,11 @@ export {
} from "./visibility"; } from "./visibility";
// Prop Expressions // Prop Expressions
export type { PropExpression, PropResolutionContext } from "./props"; export type {
PropExpression,
PropResolutionContext,
ComputedFunction,
} from "./props";
export { export {
resolvePropValue, resolvePropValue,
+165 -1
View File
@@ -1,9 +1,11 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { import {
resolvePropValue, resolvePropValue,
resolveElementProps, resolveElementProps,
resolveBindings, resolveBindings,
resolveActionParam, resolveActionParam,
_resetWarnedComputedFns,
_resetWarnedTemplatePaths,
} from "./props"; } from "./props";
import type { PropResolutionContext } from "./props"; import type { PropResolutionContext } from "./props";
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
expect(resolveActionParam(null, ctx)).toBeNull(); expect(resolveActionParam(null, ctx)).toBeNull();
}); });
}); });
// =============================================================================
// $computed expressions
// =============================================================================
describe("$computed expressions", () => {
it("calls a registered function with resolved args", () => {
const ctx: PropResolutionContext = {
stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
functions: {
fullName: (args) => `${args.first} ${args.last}`,
},
};
expect(
resolvePropValue(
{
$computed: "fullName",
args: {
first: { $state: "/form/firstName" },
last: { $state: "/form/lastName" },
},
},
ctx,
),
).toBe("Jane Doe");
});
it("calls function with no args", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {
timestamp: () => 1234567890,
},
};
expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
});
it("returns undefined for unknown function", () => {
const ctx: PropResolutionContext = {
stateModel: {},
functions: {},
};
expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
});
it("returns undefined when no functions in context", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
});
it("deduplicates warnings for the same unknown function", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
resolvePropValue({ $computed: "dedupTest" }, ctx);
resolvePropValue({ $computed: "dedupTest" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes("dedupTest"),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
});
it("resolves nested expressions in args", () => {
const ctx: PropResolutionContext = {
stateModel: { active: true, values: { a: 10, b: 20 } },
functions: {
conditionalSum: (args) => {
if (args.enabled) return (args.x as number) + (args.y as number);
return 0;
},
},
};
expect(
resolvePropValue(
{
$computed: "conditionalSum",
args: {
enabled: { $state: "/active" },
x: { $state: "/values/a" },
y: { $state: "/values/b" },
},
},
ctx,
),
).toBe(30);
});
});
// =============================================================================
// $template expressions
// =============================================================================
describe("$template expressions", () => {
it("interpolates state values into a string", () => {
const ctx: PropResolutionContext = {
stateModel: { user: { name: "Alice" }, count: 3 },
};
expect(
resolvePropValue(
{ $template: "Hello, ${/user/name}! You have ${/count} messages." },
ctx,
),
).toBe("Hello, Alice! You have 3 messages.");
});
it("replaces missing paths with empty string", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
});
it("handles template with no interpolations", () => {
const ctx: PropResolutionContext = { stateModel: {} };
expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
"No variables here",
);
});
it("handles multiple references to the same path", () => {
const ctx: PropResolutionContext = {
stateModel: { x: "A" },
};
expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
"A and A",
);
});
it("converts non-string values to strings", () => {
const ctx: PropResolutionContext = {
stateModel: { num: 42, bool: true },
};
expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
"42 is true",
);
});
it("warns when path does not start with /", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
expect(result).toBe("Hi Bob!");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('$template path "name"'),
);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
it("deduplicates warnings for the same $template path", () => {
_resetWarnedTemplatePaths();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
resolvePropValue({ $template: "Hi ${name}!" }, ctx);
const calls = warnSpy.mock.calls.filter((c) =>
String(c[0]).includes('$template path "name"'),
);
expect(calls).toHaveLength(1);
warnSpy.mockRestore();
_resetWarnedTemplatePaths();
});
});
+100 -1
View File
@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
* repeat item — resolves via `repeatBasePath + path` and exposes the * repeat item — resolves via `repeatBasePath + path` and exposes the
* absolute state path for write-back. * absolute state path for write-back.
* - `{ $cond, $then, $else }` conditionally picks a value * - `{ $cond, $then, $else }` conditionally picks a value
* - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
* registered function with resolved args and returns the result
* - `{ $template: string }` interpolates `${/path}` references in the
* string with values from the state model
* - Any other value is a literal (passthrough) * - Any other value is a literal (passthrough)
*/ */
export type PropExpression<T = unknown> = export type PropExpression<T = unknown> =
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
$cond: VisibilityCondition; $cond: VisibilityCondition;
$then: PropExpression<T>; $then: PropExpression<T>;
$else: PropExpression<T>; $else: PropExpression<T>;
}; }
| { $computed: string; args?: Record<string, unknown> }
| { $template: string };
/**
* Function signature for `$computed` expressions.
* Receives a record of resolved argument values and returns a computed result.
*/
export type ComputedFunction = (args: Record<string, unknown>) => unknown;
/** /**
* Context for resolving prop expressions. * Context for resolving prop expressions.
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
export interface PropResolutionContext extends VisibilityContext { export interface PropResolutionContext extends VisibilityContext {
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */ /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
repeatBasePath?: string; repeatBasePath?: string;
/** Named functions available for `$computed` expressions. */
functions?: Record<string, ComputedFunction>;
} }
// ============================================================================= // =============================================================================
@@ -110,6 +124,47 @@ function isCondExpression(
); );
} }
function isComputedExpression(
value: unknown,
): value is { $computed: string; args?: Record<string, unknown> } {
return (
typeof value === "object" &&
value !== null &&
"$computed" in value &&
typeof (value as Record<string, unknown>).$computed === "string"
);
}
function isTemplateExpression(value: unknown): value is { $template: string } {
return (
typeof value === "object" &&
value !== null &&
"$template" in value &&
typeof (value as Record<string, unknown>).$template === "string"
);
}
// Module-level set to avoid spamming console.warn on every render for the same
// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
// new names are no longer deduplicated (warnings still fire) but the set stops
// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
const WARNED_COMPUTED_MAX = 100;
const warnedComputedFns = new Set<string>();
/** @internal Test-only: clear the deduplication set for $computed warnings. */
export function _resetWarnedComputedFns(): void {
warnedComputedFns.clear();
}
// Same deduplication pattern for $template paths that don't start with "/".
const WARNED_TEMPLATE_MAX = 100;
const warnedTemplatePaths = new Set<string>();
/** @internal Test-only: clear the deduplication set for $template warnings. */
export function _resetWarnedTemplatePaths(): void {
warnedTemplatePaths.clear();
}
// ============================================================================= // =============================================================================
// Prop Expression Resolution // Prop Expression Resolution
// ============================================================================= // =============================================================================
@@ -193,6 +248,50 @@ export function resolvePropValue(
return resolvePropValue(result ? value.$then : value.$else, ctx); return resolvePropValue(result ? value.$then : value.$else, ctx);
} }
// $computed: call a registered function with resolved args
if (isComputedExpression(value)) {
const fn = ctx.functions?.[value.$computed];
if (!fn) {
if (!warnedComputedFns.has(value.$computed)) {
if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
warnedComputedFns.add(value.$computed);
}
console.warn(`Unknown $computed function: "${value.$computed}"`);
}
return undefined;
}
const resolvedArgs: Record<string, unknown> = {};
if (value.args) {
for (const [key, arg] of Object.entries(value.args)) {
resolvedArgs[key] = resolvePropValue(arg, ctx);
}
}
return fn(resolvedArgs);
}
// $template: interpolate ${/path} references with state values
if (isTemplateExpression(value)) {
return value.$template.replace(
/\$\{([^}]+)\}/g,
(_match, rawPath: string) => {
let path = rawPath;
if (!path.startsWith("/")) {
if (!warnedTemplatePaths.has(path)) {
if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
warnedTemplatePaths.add(path);
}
console.warn(
`$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
);
}
path = "/" + path;
}
const resolved = getByPath(ctx.stateModel, path);
return resolved != null ? String(resolved) : "";
},
);
}
// Arrays: resolve each element // Arrays: resolve each element
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map((item) => resolvePropValue(item, ctx)); return value.map((item) => resolvePropValue(item, ctx));
+65 -1
View File
@@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.", "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
); );
lines.push(""); lines.push("");
lines.push(
'4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
);
lines.push(
' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
);
lines.push("");
// $computed section — only emit when catalog defines functions
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
lines.push(
'5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
);
lines.push(
' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
);
lines.push(" Available functions:");
for (const name of Object.keys(
catalogFunctions as Record<string, unknown>,
)) {
lines.push(` - ${name}`);
}
lines.push("");
}
// Validation section — only emit when at least one component has a `checks` prop // Validation section — only emit when at least one component has a `checks` prop
const hasChecksComponents = allComponents const hasChecksComponents = allComponents
@@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(" - numeric — value must be a number"); lines.push(" - numeric — value must be a number");
lines.push(" - url — valid URL format"); lines.push(" - url — valid URL format");
lines.push( lines.push(
' - matches — must equal another field (args: { "other": "value" })', ' - matches — must equal another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
);
lines.push(
' - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
); );
lines.push(""); lines.push("");
lines.push("Example:"); lines.push("Example:");
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push(""); lines.push("");
} }
// State watchers section — only emit when actions are available (watchers
// trigger actions, so the section is irrelevant without them).
if (hasCustomActions || hasBuiltInActions) {
lines.push("STATE WATCHERS:");
lines.push(
"Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
);
lines.push(
"Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
);
lines.push("");
lines.push(
"Example (cascading select — country changes trigger city loading):",
);
lines.push(
` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
);
lines.push("");
lines.push(
"Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
);
lines.push(
"IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
);
lines.push("");
}
// Rules // Rules
lines.push("RULES:"); lines.push("RULES:");
const baseRules = const baseRules =
+250
View File
@@ -0,0 +1,250 @@
import { describe, it, expect } from "vitest";
import type { Spec } from "./types";
import { validateSpec, autoFixSpec } from "./spec-validator";
// =============================================================================
// validateSpec
// =============================================================================
describe("validateSpec", () => {
it("returns valid for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["child1"] },
child1: { type: "Text", props: { text: "hello" }, children: [] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it("detects missing root", () => {
const spec = {
root: "",
elements: { a: { type: "T", props: {}, children: [] } },
} as Spec;
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
});
it("detects root_not_found", () => {
const spec: Spec = {
root: "missing",
elements: { a: { type: "T", props: {}, children: [] } },
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
});
it("detects empty spec", () => {
const spec: Spec = { root: "r", elements: {} };
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
});
it("detects missing_child", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: ["nonexistent"] },
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
});
it("detects visible_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { visible: { $state: "/show" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
});
it("detects on_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { on: { press: { action: "doSomething" } } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
});
it("detects repeat_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
});
it("detects watch_in_props", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const result = validateSpec(spec);
expect(result.valid).toBe(false);
const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
expect(watchIssue).toBeDefined();
expect(watchIssue!.elementKey).toBe("root");
});
it("detects orphaned elements when checkOrphans is true", () => {
const spec: Spec = {
root: "root",
elements: {
root: { type: "Stack", props: {}, children: [] },
orphan: { type: "Text", props: {}, children: [] },
},
};
const result = validateSpec(spec, { checkOrphans: true });
expect(result.valid).toBe(true);
expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
});
});
// =============================================================================
// autoFixSpec
// =============================================================================
describe("autoFixSpec", () => {
it("moves visible from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Text",
props: { text: "hi", visible: { $state: "/show" } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).visible,
).toBeUndefined();
expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
expect(fixes.some((f) => f.includes("visible"))).toBe(true);
});
it("moves on from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Button",
props: { label: "OK", on: { press: { action: "submit" } } },
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).on,
).toBeUndefined();
expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
});
it("moves repeat from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { repeat: { statePath: "/items" } },
children: ["child"],
},
child: { type: "Text", props: {}, children: [] },
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).repeat,
).toBeUndefined();
expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
});
it("moves watch from props to element level", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Select",
props: {
label: "Country",
watch: {
"/form/country": { action: "loadCities" },
},
},
children: [],
},
},
};
const { spec: fixed, fixes } = autoFixSpec(spec);
expect(
(fixed.elements.root.props as Record<string, unknown>).watch,
).toBeUndefined();
expect(fixed.elements.root.watch).toEqual({
"/form/country": { action: "loadCities" },
});
expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
});
it("returns no fixes for a correct spec", () => {
const spec: Spec = {
root: "root",
elements: {
root: {
type: "Stack",
props: { direction: "vertical" },
children: [],
watch: { "/x": { action: "y" } },
},
},
};
const { fixes } = autoFixSpec(spec);
expect(fixes).toHaveLength(0);
});
});
+27 -1
View File
@@ -28,7 +28,8 @@ export interface SpecIssue {
| "orphaned_element" | "orphaned_element"
| "empty_spec" | "empty_spec"
| "on_in_props" | "on_in_props"
| "repeat_in_props"; | "repeat_in_props"
| "watch_in_props";
} }
/** /**
@@ -151,6 +152,16 @@ export function validateSpec(
code: "repeat_in_props", code: "repeat_in_props",
}); });
} }
// 3e. `watch` inside props (should be a top-level field)
if (props && "watch" in props && props.watch !== undefined) {
issues.push({
severity: "error",
message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
elementKey: key,
code: "watch_in_props",
});
}
} }
// 4. Orphaned elements (optional) // 4. Orphaned elements (optional)
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
fixes.push(`Moved "repeat" from props to element level on "${key}".`); fixes.push(`Moved "repeat" from props to element level on "${key}".`);
} }
currentProps = fixed.props as Record<string, unknown> | undefined;
if (
currentProps &&
"watch" in currentProps &&
currentProps.watch !== undefined
) {
const { watch, ...restProps } = currentProps;
fixed = {
...fixed,
props: restProps,
watch: watch as UIElement["watch"],
};
fixes.push(`Moved "watch" from props to element level on "${key}".`);
}
fixedElements[key] = fixed; fixedElements[key] = fixed;
} }
+6
View File
@@ -69,6 +69,12 @@ export interface UIElement<
on?: Record<string, ActionBinding | ActionBinding[]>; on?: Record<string, ActionBinding | ActionBinding[]>;
/** Repeat children once per item in a state array */ /** Repeat children once per item in a state array */
repeat?: { statePath: string; key?: string }; repeat?: { statePath: string; key?: string };
/**
* State watchers — maps JSON Pointer state paths to action bindings.
* When the value at a watched path changes, the bound actions fire.
* Useful for cascading dependencies (e.g. country → city option loading).
*/
watch?: Record<string, ActionBinding | ActionBinding[]>;
} }
/** /**
+341
View File
@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
).toBe(false); ).toBe(false);
}); });
}); });
describe("equalTo", () => {
it("passes when values are equal", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
true,
);
});
it("fails when values differ", () => {
expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
false,
);
});
});
describe("lessThan", () => {
it("passes when value is less than other", () => {
expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
});
it("fails when value is greater than other", () => {
expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(true);
});
it("fails for equal strings", () => {
expect(
builtInValidationFunctions.lessThan("2026-01-01", {
other: "2026-01-01",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
false,
);
});
it("returns false when other is undefined", () => {
expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
false,
);
});
});
describe("greaterThan", () => {
it("passes when value is greater than other", () => {
expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
true,
);
});
it("fails when value equals other", () => {
expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
false,
);
});
it("fails when value is less than other", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
false,
);
});
it("coerces numeric string vs number", () => {
expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
true,
);
});
it("fails coercion when non-numeric string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
false,
);
});
it("passes for string comparison (ISO dates)", () => {
expect(
builtInValidationFunctions.greaterThan("2026-06-15", {
other: "2026-01-01",
}),
).toBe(true);
});
it("fails for lesser strings", () => {
expect(
builtInValidationFunctions.greaterThan("2026-01-01", {
other: "2026-06-15",
}),
).toBe(false);
});
it("returns false when value is empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
false,
);
});
it("returns false when other is empty string", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
false,
);
});
it("returns false when value is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
false,
);
});
it("returns false when other is empty string vs non-empty string", () => {
expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
false,
);
});
it("returns false when other is null", () => {
expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
false,
);
});
it("returns false when value is undefined", () => {
expect(
builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
).toBe(false);
});
it("returns false when value is null", () => {
expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
false,
);
});
});
describe("requiredIf", () => {
it("passes when condition is falsy (field not required)", () => {
expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
true,
);
expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
true,
);
expect(
builtInValidationFunctions.requiredIf("", { field: undefined }),
).toBe(true);
});
it("fails when condition is truthy and value is empty", () => {
expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
false,
);
expect(
builtInValidationFunctions.requiredIf(null, { field: "yes" }),
).toBe(false);
expect(
builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
).toBe(false);
});
it("passes when condition is truthy and value is present", () => {
expect(
builtInValidationFunctions.requiredIf("hello", { field: true }),
).toBe(true);
expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
true,
);
});
});
}); });
describe("runValidationCheck", () => { describe("runValidationCheck", () => {
@@ -391,6 +611,22 @@ describe("check helper", () => {
}); });
}); });
describe("numeric", () => {
it("creates numeric check with default message", () => {
const c = check.numeric();
expect(c.type).toBe("numeric");
expect(c.message).toBe("Must be a number");
});
it("creates numeric check with custom message", () => {
const c = check.numeric("Numbers only");
expect(c.type).toBe("numeric");
expect(c.message).toBe("Numbers only");
});
});
describe("matches", () => { describe("matches", () => {
it("creates matches check with path reference", () => { it("creates matches check with path reference", () => {
const c = check.matches("/password", "Passwords must match"); const c = check.matches("/password", "Passwords must match");
@@ -400,4 +636,109 @@ describe("check helper", () => {
expect(c.message).toBe("Passwords must match"); expect(c.message).toBe("Passwords must match");
}); });
}); });
describe("equalTo", () => {
it("creates equalTo check with path reference", () => {
const c = check.equalTo("/email", "Emails must match");
expect(c.type).toBe("equalTo");
expect(c.args).toEqual({ other: { $state: "/email" } });
expect(c.message).toBe("Emails must match");
});
});
describe("lessThan", () => {
it("creates lessThan check with path reference", () => {
const c = check.lessThan("/maxValue", "Must be less");
expect(c.type).toBe("lessThan");
expect(c.args).toEqual({ other: { $state: "/maxValue" } });
expect(c.message).toBe("Must be less");
});
});
describe("greaterThan", () => {
it("creates greaterThan check with path reference", () => {
const c = check.greaterThan("/minValue");
expect(c.type).toBe("greaterThan");
expect(c.args).toEqual({ other: { $state: "/minValue" } });
});
});
describe("requiredIf", () => {
it("creates requiredIf check with path reference", () => {
const c = check.requiredIf("/toggle", "Required when toggle is on");
expect(c.type).toBe("requiredIf");
expect(c.args).toEqual({ field: { $state: "/toggle" } });
expect(c.message).toBe("Required when toggle is on");
});
});
});
// =============================================================================
// Deep arg resolution in runValidationCheck
// =============================================================================
describe("deep arg resolution", () => {
it("resolves nested $state refs in validation args", () => {
const result = runValidationCheck(
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
{
value: "secret123",
stateModel: { form: { password: "secret123" } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in cross-field lessThan check", () => {
const result = runValidationCheck(
{
type: "lessThan",
args: { other: { $state: "/form/maxPrice" } },
message: "Must be less than max price",
},
{
value: 50,
stateModel: { form: { maxPrice: 100 } },
},
);
expect(result.valid).toBe(true);
});
it("resolves $state in requiredIf check", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: true } },
},
);
expect(result.valid).toBe(false);
});
it("passes requiredIf when condition is false", () => {
const result = runValidationCheck(
{
type: "requiredIf",
args: { field: { $state: "/form/enableEmail" } },
message: "Email is required",
},
{
value: "",
stateModel: { form: { enableEmail: false } },
},
);
expect(result.valid).toBe(true);
});
}); });
+104 -4
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { DynamicValue, StateModel, VisibilityCondition } from "./types"; import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
import { DynamicValueSchema, resolveDynamicValue } from "./types"; import { DynamicValueSchema, resolveDynamicValue } from "./types";
import { VisibilityConditionSchema, evaluateVisibility } from "./visibility"; import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
import { resolvePropValue } from "./props";
/** /**
* Validation check definition * Validation check definition
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
description?: string; description?: string;
} }
const matchesImpl: ValidationFunction = (
value: unknown,
args?: Record<string, unknown>,
) => {
const other = args?.other;
return value === other;
};
/** /**
* Built-in validation functions * Built-in validation functions
*/ */
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
/** /**
* Check if value matches another field * Check if value matches another field
*/ */
matches: (value: unknown, args?: Record<string, unknown>) => { matches: matchesImpl,
/**
* Alias for matches with a more descriptive name for cross-field equality
*/
equalTo: matchesImpl,
/**
* Check if value is less than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "3" vs number 5).
*/
lessThan: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other; const other = args?.other;
return value === other; if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value < other;
if (typeof value === "string" && typeof other === "string")
return value < other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
return false;
},
/**
* Check if value is greater than another field's value.
* Supports numbers, strings (useful for ISO date comparison), and
* cross-type numeric coercion (e.g. string "7" vs number 5).
*/
greaterThan: (value: unknown, args?: Record<string, unknown>) => {
const other = args?.other;
if (value == null || other == null || value === "" || other === "")
return false;
if (typeof value === "number" && typeof other === "number")
return value > other;
if (typeof value === "string" && typeof other === "string")
return value > other;
const numVal = Number(value);
const numOther = Number(other);
if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
return false;
},
/**
* Required only when a condition is met.
* Uses JS truthiness: 0, false, "", null, and undefined are all
* treated as "condition not met" (field not required), matching
* the visibility system's bare-condition semantics.
*/
requiredIf: (value: unknown, args?: Record<string, unknown>) => {
const condition = args?.field;
if (!condition) return true;
if (value === null || value === undefined) return false;
if (typeof value === "string") return value.trim().length > 0;
if (Array.isArray(value)) return value.length > 0;
return true;
}, },
}; };
@@ -209,11 +273,12 @@ export function runValidationCheck(
): ValidationCheckResult { ): ValidationCheckResult {
const { value, stateModel, customFunctions } = ctx; const { value, stateModel, customFunctions } = ctx;
// Resolve args // Resolve args using resolvePropValue so nested $state refs (and any other
// prop expressions) are handled consistently with the rest of the system.
const resolvedArgs: Record<string, unknown> = {}; const resolvedArgs: Record<string, unknown> = {};
if (check.args) { if (check.args) {
for (const [key, argValue] of Object.entries(check.args)) { for (const [key, argValue] of Object.entries(check.args)) {
resolvedArgs[key] = resolveDynamicValue(argValue, stateModel); resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
} }
} }
@@ -326,6 +391,11 @@ export const check = {
message, message,
}), }),
numeric: (message = "Must be a number"): ValidationCheck => ({
type: "numeric",
message,
}),
matches: ( matches: (
otherPath: string, otherPath: string,
message = "Fields must match", message = "Fields must match",
@@ -334,4 +404,34 @@ export const check = {
args: { other: { $state: otherPath } }, args: { other: { $state: otherPath } },
message, message,
}), }),
equalTo: (
otherPath: string,
message = "Fields must match",
): ValidationCheck => ({
type: "equalTo",
args: { other: { $state: otherPath } },
message,
}),
lessThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "lessThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be less than the compared field",
}),
greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
type: "greaterThan",
args: { other: { $state: otherPath } },
message: message ?? "Must be greater than the compared field",
}),
requiredIf: (
fieldPath: string,
message = "This field is required",
): ValidationCheck => ({
type: "requiredIf",
args: { field: { $state: fieldPath } },
message,
}),
}; };
+13
View File
@@ -1,5 +1,18 @@
# @json-render/jotai # @json-render/jotai
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/jotai", "name": "@json-render/jotai",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore", "description": "Jotai adapter for json-render StateStore",
"keywords": [ "keywords": [
+14
View File
@@ -1,5 +1,19 @@
# @json-render/react-native # @json-render/react-native
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/react-native", "name": "@json-render/react-native",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.", "description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [ "keywords": [
@@ -56,10 +56,10 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@json-render/core": "workspace:*", "@json-render/core": "workspace:*"
"@internal/react-state": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*", "@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3", "@types/react": "19.2.3",
"react-native": "0.83.1", "react-native": "0.83.1",
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts"], entry: ["src/index.ts", "src/schema.ts", "src/catalog.ts"],
format: ["cjs", "esm"], format: ["cjs", "esm"],
dts: true, dts: { resolve: ["@internal/react-state"] },
sourcemap: true, sourcemap: true,
clean: true, clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "react-native", "@json-render/core", "zod"], external: ["react", "react-native", "@json-render/core", "zod"],
}); });
+14
View File
@@ -1,5 +1,19 @@
# @json-render/react-pdf # @json-render/react-pdf
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/react-pdf", "name": "@json-render/react-pdf",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.", "description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [ "keywords": [
@@ -61,10 +61,10 @@
}, },
"dependencies": { "dependencies": {
"@json-render/core": "workspace:*", "@json-render/core": "workspace:*",
"@internal/react-state": "workspace:*",
"@react-pdf/renderer": "^4.3.2" "@react-pdf/renderer": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*", "@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3", "@types/react": "19.2.3",
"tsup": "^8.0.2", "tsup": "^8.0.2",
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"], entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
format: ["cjs", "esm"], format: ["cjs", "esm"],
dts: true, dts: { resolve: ["@internal/react-state"] },
sourcemap: true, sourcemap: true,
clean: true, clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"], external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
}); });
+13
View File
@@ -1,5 +1,18 @@
# @internal/react-state # @internal/react-state
## 0.8.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.8.2
### Patch Changes
- @json-render/core@0.9.1
## 0.8.1 ## 0.8.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@internal/react-state", "name": "@internal/react-state",
"version": "0.8.1", "version": "0.8.3",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages", "description": "Shared React state context for json-render renderer packages",
+83
View File
@@ -1,5 +1,88 @@
# @json-render/react # @json-render/react
## 0.10.0
### Minor Changes
- 9cef4e9: Dynamic forms, Vue renderer, XState Store adapter, and computed values.
### New: `@json-render/vue` Package
Vue 3 renderer for json-render. Full feature parity with `@json-render/react` including data binding, visibility conditions, actions, validation, repeat scopes, and streaming.
- `defineRegistry` — create type-safe component registries from catalogs
- `Renderer` — render specs as Vue component trees
- Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`
- Composables: `useStateStore`, `useStateValue`, `useStateBinding`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`
- Streaming: `useUIStream`, `useChatUI`
- External store support via `StateStore` interface
### New: `@json-render/xstate` Package
XState Store (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend.
- `xstateStoreStateStore({ atom })` — creates a `StateStore` from an `@xstate/store` atom
- Requires `@xstate/store` v3+
### New: `$computed` Expressions
Call registered functions from prop expressions:
- `{ "$computed": "functionName", "args": { "key": <expression> } }` — calls a named function with resolved args
- Functions registered via catalog and provided at runtime through `functions` prop on `JSONUIProvider` / `createRenderer`
- `ComputedFunction` type exported from `@json-render/core`
### New: `$template` Expressions
Interpolate state values into strings:
- `{ "$template": "Hello, ${/user/name}!" }` — replaces `${/path}` references with state values
- Missing paths resolve to empty string
### New: State Watchers
React to state changes by triggering actions:
- `watch` field on elements maps state paths to action bindings
- Fires when watched values change (not on initial render)
- Supports cascading dependencies (e.g. country → city loading)
- `watch` is a top-level field on elements (sibling of type/props/children), not inside props
- Spec validator detects and auto-fixes `watch` placed inside props
### New: Cross-Field Validation Functions
New built-in validation functions for cross-field comparisons:
- `equalTo` — alias for `matches` with clearer semantics
- `lessThan` — value must be less than another field (numbers, strings, coerced)
- `greaterThan` — value must be greater than another field
- `requiredIf` — required only when a condition field is truthy
- Validation args now resolve through `resolvePropValue` for consistent `$state` expression handling
### New: `validateForm` Action (React)
Built-in action that validates all registered form fields at once:
- Runs `validateAll()` synchronously and writes `{ valid, errors }` to state
- Default state path: `/formValidation` (configurable via `statePath` param)
- Added to React schema's built-in actions list
### Improved: shadcn/ui Validation
All form components now support validation:
- Checkbox, Radio, Switch — added `checks` and `validateOn` props
- Input, Textarea, Select — added `validateOn` prop (controls timing: change/blur/submit)
- Shared validation schemas reduce catalog definition duplication
### Improved: React Provider Tree
Reordered provider nesting so `ValidationProvider` wraps `ActionProvider`, enabling `validateForm` to access validation state. Added `useOptionalValidation` hook for non-throwing access.
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- b103676: Fix install failure caused by `@internal/react-state` (a private workspace package) being listed as a published dependency. The internal package is now bundled into each renderer's output at build time, so consumers no longer need to resolve it from npm.
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes
+72 -2
View File
@@ -261,6 +261,7 @@ const { errors, validate } = useFieldValidation("/form/email", {
| `useActions()` | Access action context | | `useActions()` | Access action context |
| `useAction(name)` | Get a single action dispatch function | | `useAction(name)` | Get a single action dispatch function |
| `useFieldValidation(path, config)` | Field validation state | | `useFieldValidation(path, config)` | Field validation state |
| `useOptionalValidation()` | Non-throwing validation context (returns `null` if no provider) |
| `useUIStream(options)` | Stream specs from an API endpoint | | `useUIStream(options)` | Stream specs from an API endpoint |
## Visibility Conditions ## Visibility Conditions
@@ -324,11 +325,60 @@ Any prop value can use data-driven expressions that resolve at render time. The
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`. For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
### `$template` and `$computed`
```json
{
"label": { "$template": "Hello, ${/user/name}!" },
"fullName": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
}
```
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`:
```tsx
<JSONUIProvider
spec={spec}
catalog={catalog}
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
>
```
See [@json-render/core](../core/README.md) for full expression syntax. See [@json-render/core](../core/README.md) for full expression syntax.
## State Watchers
Elements can declare a `watch` field to trigger actions when state values change:
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`), not inside `props`. Watchers only fire on value changes, not on initial render.
## Built-in Actions ## Built-in Actions
The `setState`, `pushState`, and `removeState` actions are built into the React 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: The `setState`, `pushState`, `removeState`, and `validateForm` actions are built into the React schema and handled automatically by `ActionProvider`. They are injected into AI prompts without needing to be declared in your catalog's `actions`:
```json ```json
{ {
@@ -344,6 +394,26 @@ The `setState`, `pushState`, and `removeState` actions are built into the React
} }
``` ```
### `validateForm`
Validate all registered form fields at once and write the result to state:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
Writes `{ valid: boolean, errors: Record<string, string[]> }` to the specified state path (defaults to `/formValidation`).
## Component Props ## Component Props
When using `defineRegistry`, components receive these props: When using `defineRegistry`, components receive these props:
@@ -451,7 +521,7 @@ function App() {
|--------|---------| |--------|---------|
| `defineRegistry` | Create a type-safe component registry from a catalog | | `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry | | `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`) | | `schema` | Element tree schema (includes built-in actions: `setState`, `pushState`, `removeState`, `validateForm`) |
| `useStateStore` | Access state context | | `useStateStore` | Access state context |
| `useStateValue` | Get single value from state | | `useStateValue` | Get single value from state |
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions | | `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/react", "name": "@json-render/react",
"version": "0.9.0", "version": "0.10.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.", "description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [ "keywords": [
@@ -50,10 +50,10 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@json-render/core": "workspace:*", "@json-render/core": "workspace:*"
"@internal/react-state": "workspace:*"
}, },
"devDependencies": { "devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*", "@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3", "@types/react": "19.2.3",
"tsup": "^8.0.2", "tsup": "^8.0.2",
+31 -2
View File
@@ -17,6 +17,7 @@ import {
type ResolvedAction, type ResolvedAction,
} from "@json-render/core"; } from "@json-render/core";
import { useStateStore } from "./state"; import { useStateStore } from "./state";
import { useOptionalValidation } from "./validation";
/** /**
* Generate a unique ID for use with the "$id" token. * Generate a unique ID for use with the "$id" token.
@@ -137,6 +138,7 @@ export function ActionProvider({
children, children,
}: ActionProviderProps) { }: ActionProviderProps) {
const { get, set, getSnapshot } = useStateStore(); const { get, set, getSnapshot } = useStateStore();
const validation = useOptionalValidation();
const [handlers, setHandlers] = const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers); useState<Record<string, ActionHandler>>(initialHandlers);
@@ -227,13 +229,40 @@ export function ActionProvider({
if (previousScreen) { if (previousScreen) {
set("/currentScreen", previousScreen); set("/currentScreen", previousScreen);
} else { } else {
// Sentinel empty string = clear currentScreen (return to default)
set("/currentScreen", undefined); set("/currentScreen", undefined);
} }
} }
return; return;
} }
// Built-in: validateForm triggers validateAll from the ValidationProvider
// and writes the result to a state path (default: /formValidation).
// IMPORTANT: validateAll() is synchronous — it runs all registered field
// validations and returns immediately. This guarantees that the next action
// in a sequential list (e.g. [validateForm, submitForm]) can read the
// validation result from state without awaiting an extra tick.
if (resolved.action === "validateForm") {
const validateAll = validation?.validateAll;
if (!validateAll) {
console.warn(
"validateForm action was dispatched but no ValidationProvider is connected. " +
"Ensure ValidationProvider is rendered inside the provider tree.",
);
return;
}
const valid = validateAll();
const errors: Record<string, string[]> = {};
for (const [path, fs] of Object.entries(validation.fieldStates)) {
if (fs.result && !fs.result.valid) {
errors[path] = fs.result.errors;
}
}
const statePath =
(resolved.params?.statePath as string) || "/formValidation";
set(statePath, { valid, errors });
return;
}
const handler = handlers[resolved.action]; const handler = handlers[resolved.action];
if (!handler) { if (!handler) {
@@ -300,7 +329,7 @@ export function ActionProvider({
}); });
} }
}, },
[handlers, get, set, getSnapshot, navigate], [handlers, get, set, getSnapshot, navigate, validation],
); );
const confirm = useCallback(() => { const confirm = useCallback(() => {
+43 -26
View File
@@ -3,10 +3,10 @@
import React, { import React, {
createContext, createContext,
useContext, useContext,
useRef,
useState, useState,
useCallback, useCallback,
useMemo, useMemo,
useRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { import {
@@ -130,15 +130,14 @@ export function ValidationProvider({
customFunctions = {}, customFunctions = {},
children, children,
}: ValidationProviderProps) { }: ValidationProviderProps) {
const { state } = useStateStore(); const { state, getSnapshot } = useStateStore();
// Keep a ref to the latest state so `validate` doesn't change on every
// state update — preventing the entire validation context from churning.
const stateRef = useRef(state);
stateRef.current = state;
const [fieldStates, setFieldStates] = useState< const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState> 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< const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig> Record<string, ValidationConfig>
>({}); >({});
@@ -160,8 +159,10 @@ export function ValidationProvider({
const validate = useCallback( const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => { (path: string, config: ValidationConfig): ValidationResult => {
// Walk the nested state object using JSON Pointer segments // Read from the store directly so validation sees values written in the
const currentState = stateRef.current; // same synchronous handler (e.g. setValue then validate in onChange).
// Using React state would return the stale pre-render snapshot.
const currentState = getSnapshot();
const segments = path.split("/").filter(Boolean); const segments = path.split("/").filter(Boolean);
let value: unknown = currentState; let value: unknown = currentState;
for (const seg of segments) { for (const seg of segments) {
@@ -178,37 +179,39 @@ export function ValidationProvider({
customFunctions, customFunctions,
}); });
setFieldStates((prev) => ({ const newFieldState: FieldValidationState = {
...prev, touched: fieldStatesRef.current[path]?.touched ?? true,
[path]: {
touched: prev[path]?.touched ?? true,
validated: true, validated: true,
result, result,
}, };
})); fieldStatesRef.current = {
...fieldStatesRef.current,
[path]: newFieldState,
};
setFieldStates(fieldStatesRef.current);
return result; return result;
}, },
[customFunctions], [customFunctions, getSnapshot],
); );
const touch = useCallback((path: string) => { const touch = useCallback((path: string) => {
setFieldStates((prev) => ({ fieldStatesRef.current = {
...prev, ...fieldStatesRef.current,
[path]: { [path]: {
...prev[path], ...fieldStatesRef.current[path],
touched: true, touched: true,
validated: prev[path]?.validated ?? false, validated: fieldStatesRef.current[path]?.validated ?? false,
result: prev[path]?.result ?? null, result: fieldStatesRef.current[path]?.result ?? null,
}, },
})); };
setFieldStates(fieldStatesRef.current);
}, []); }, []);
const clear = useCallback((path: string) => { const clear = useCallback((path: string) => {
setFieldStates((prev) => { const { [path]: _, ...rest } = fieldStatesRef.current;
const { [path]: _, ...rest } = prev; fieldStatesRef.current = rest;
return rest; setFieldStates(rest);
});
}, []); }, []);
const validateAll = useCallback(() => { const validateAll = useCallback(() => {
@@ -227,7 +230,11 @@ export function ValidationProvider({
const value = useMemo<ValidationContextValue>( const value = useMemo<ValidationContextValue>(
() => ({ () => ({
customFunctions, customFunctions,
fieldStates, // Getter returns the mutable ref so callers that read fieldStates
// synchronously after validateAll() see the latest values.
get fieldStates() {
return fieldStatesRef.current;
},
validate, validate,
touch, touch,
clear, clear,
@@ -236,6 +243,8 @@ export function ValidationProvider({
}), }),
[ [
customFunctions, customFunctions,
// fieldStates (React state) stays in deps so the context value object
// is recreated on re-render, triggering downstream consumers.
fieldStates, fieldStates,
validate, validate,
touch, touch,
@@ -263,6 +272,14 @@ export function useValidation(): ValidationContextValue {
return ctx; return ctx;
} }
/**
* Non-throwing variant of useValidation.
* Returns null when no ValidationProvider is present.
*/
export function useOptionalValidation(): ValidationContextValue | null {
return useContext(ValidationContext);
}
/** /**
* Hook to get validation state for a field * Hook to get validation state for a field
*/ */
+773
View File
@@ -0,0 +1,773 @@
import { describe, it, expect, vi } from "vitest";
import React, { useState, useCallback, useMemo } from "react";
import { render, act, fireEvent, screen } from "@testing-library/react";
import type { Spec } from "@json-render/core";
import {
JSONUIProvider,
Renderer,
type ComponentRenderProps,
} from "./renderer";
import { useStateStore } from "./contexts/state";
import { useFieldValidation } from "./contexts/validation";
import { useBoundProp } from "./hooks";
// =============================================================================
// Stub components
// =============================================================================
function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
return (
<button data-testid="btn" onClick={() => emit("press")}>
{element.props.label}
</button>
);
}
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
const value = element.props.text;
return (
<span data-testid="text">
{value == null
? ""
: typeof value === "string"
? value
: JSON.stringify(value)}
</span>
);
}
function InputField({
element,
bindings,
}: ComponentRenderProps<{
label?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() => (hasValidation ? { checks: props.checks ?? [] } : undefined),
[hasValidation, props.checks],
);
const { errors } = useFieldValidation(bindings?.value ?? "", config);
return (
<div>
{props.label && <label>{props.label}</label>}
<input
data-testid="input"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{errors.length > 0 && <span data-testid="input-error">{errors[0]}</span>}
</div>
);
}
function SelectField({
element,
bindings,
}: ComponentRenderProps<{ label?: string; value?: string }>) {
const props = element.props;
const [boundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return <span data-testid="select-value">{boundValue ?? ""}</span>;
}
/**
* Select stub that mirrors the real shadcn Select validation behavior:
* calls setValue then validate() synchronously in onValueChange.
*/
function ValidatedSelect({
element,
bindings,
emit,
}: ComponentRenderProps<{
label?: string;
name?: string;
options?: string[];
placeholder?: string;
value?: string;
checks?: Array<{
type: string;
message: string;
args?: Record<string, unknown>;
}>;
validateOn?: "change" | "blur" | "submit";
}>) {
const props = element.props;
const [boundValue, setBoundValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
const [localValue, setLocalValue] = useState("");
const isBound = !!bindings?.value;
const value = isBound ? (boundValue ?? "") : localValue;
const setValue = isBound ? setBoundValue : setLocalValue;
const validateOn = props.validateOn ?? "change";
const hasValidation = !!(bindings?.value && props.checks?.length);
const config = useMemo(
() =>
hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
[hasValidation, props.checks, validateOn],
);
const { errors, validate } = useFieldValidation(
bindings?.value ?? "",
config,
);
const options = props.options ?? [];
return (
<div>
{props.label && <label>{props.label}</label>}
<select
data-testid={`select-${props.name ?? "default"}`}
value={value}
onChange={(e) => {
setValue(e.target.value);
if (hasValidation && validateOn === "change") validate();
emit("change");
}}
>
<option value="">{props.placeholder ?? "Select..."}</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{errors.length > 0 && (
<span data-testid={`select-error-${props.name ?? "default"}`}>
{errors[0]}
</span>
)}
</div>
);
}
function StateProbe() {
const { state } = useStateStore();
return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
}
const registry = { Button, Text, Input: InputField, Select: SelectField };
function getState(): Record<string, unknown> {
return JSON.parse(screen.getByTestId("state-probe").textContent!);
}
// =============================================================================
// $computed expressions in rendering
// =============================================================================
describe("$computed expressions in rendering", () => {
it("resolves a $computed prop using provided functions", async () => {
const spec: Spec = {
state: { first: "Jane", last: "Doe" },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$computed: "fullName",
args: {
first: { $state: "/first" },
last: { $state: "/last" },
},
},
},
children: [],
},
},
};
const functions = {
fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
};
render(
<JSONUIProvider
registry={registry}
initialState={spec.state}
functions={functions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
});
it("renders gracefully when functions prop is omitted", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $computed: "missing" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("");
warnSpy.mockRestore();
});
});
// =============================================================================
// $template expressions in rendering
// =============================================================================
describe("$template expressions in rendering", () => {
it("interpolates state values into a template string", () => {
const spec: Spec = {
state: { user: { name: "Alice" }, count: 3 },
root: "main",
elements: {
main: {
type: "Text",
props: {
text: {
$template: "Hello, ${/user/name}! You have ${/count} messages.",
},
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe(
"Hello, Alice! You have 3 messages.",
);
});
it("resolves missing paths to empty string", () => {
const spec: Spec = {
state: {},
root: "main",
elements: {
main: {
type: "Text",
props: {
text: { $template: "Hi ${/name}!" },
},
children: [],
},
},
};
render(
<JSONUIProvider registry={registry} initialState={spec.state}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>,
);
expect(screen.getByTestId("text").textContent).toBe("Hi !");
});
});
// =============================================================================
// Watchers
// =============================================================================
describe("watchers (watch field)", () => {
it("does not fire on initial render, fires when watched state changes", async () => {
const loadCities = vi.fn(async (params: Record<string, unknown>) => {
// no-op, just tracking the call
});
const spec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "main",
elements: {
main: {
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: [],
},
},
};
// Add watcher as a child of a wrapper so both render
const wrapperSpec: Spec = {
...spec,
root: "wrapper",
elements: {
...spec.elements,
wrapper: {
type: "Button",
props: { label: "wrapper" },
children: ["main", "watcher"],
},
},
};
// Use a Stack-like wrapper -- but since we only have Button/Text/Select
// stubs, we need a container. Let's add a simple Stack stub.
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => {
return <div data-testid="stack">{children}</div>;
};
const reg = { ...registry, Stack };
const stackSpec: Spec = {
state: { form: { country: "" }, citiesLoaded: false },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Set Country" },
on: {
press: [
{
action: "setState",
params: { statePath: "/form/country", value: "US" },
},
],
},
children: [],
},
watcher: {
type: "Select",
props: { value: { $state: "/form/country" } },
watch: {
"/form/country": {
action: "loadCities",
params: { country: { $state: "/form/country" } },
},
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={stackSpec.state}
handlers={{ loadCities }}
>
<Renderer spec={stackSpec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Not called on initial render
expect(loadCities).not.toHaveBeenCalled();
// Change the watched state path
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
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 Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { value: "a" },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["btn", "watcher"],
},
btn: {
type: "Button",
props: { label: "Change" },
on: {
press: [
{
action: "setState",
params: { statePath: "/value", value: "b" },
},
],
},
children: [],
},
watcher: {
type: "Text",
props: { text: { $state: "/value" } },
watch: {
"/value": [{ action: "action1" }, { action: "action2" }],
},
children: [],
},
},
};
render(
<JSONUIProvider
registry={reg}
initialState={spec.state}
handlers={{ action1, action2 }}
>
<Renderer spec={spec} registry={reg} />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
expect(action1).toHaveBeenCalledTimes(1);
expect(action2).toHaveBeenCalledTimes(1);
});
});
// =============================================================================
// validateForm action
// =============================================================================
describe("validateForm action", () => {
it("writes { valid: false } when a required field is empty", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
// Click submit with empty email
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({
valid: false,
errors: { "/form/email": ["Email is required"] },
});
});
it("writes { valid: true } when all fields pass validation", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { email: "test@example.com" }, result: null },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["emailInput", "submitBtn"],
},
emailInput: {
type: "Input",
props: {
label: "Email",
value: { $bindState: "/form/email" },
checks: [{ type: "required", message: "Email is required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.result).toEqual({ valid: true, errors: {} });
});
it("defaults to /formValidation when no statePath is provided", async () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const reg = { ...registry, Stack };
const spec: Spec = {
state: { form: { name: "filled" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["nameInput", "submitBtn"],
},
nameInput: {
type: "Input",
props: {
label: "Name",
value: { $bindState: "/form/name" },
checks: [{ type: "required", message: "Required" }],
},
children: [],
},
submitBtn: {
type: "Button",
props: { label: "Submit" },
on: {
press: [{ action: "validateForm" }],
},
children: [],
},
},
};
render(
<JSONUIProvider registry={reg} initialState={spec.state}>
<Renderer spec={spec} registry={reg} />
<StateProbe />
</JSONUIProvider>,
);
await act(async () => {
fireEvent.click(screen.getByTestId("btn"));
});
const state = getState();
expect(state.formValidation).toEqual({ valid: true, errors: {} });
});
});
// =============================================================================
// Select validate-on-change timing (#151)
// =============================================================================
describe("Select validate-on-change sees the new value, not the stale value", () => {
const Stack = ({
children,
}: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
const regWithSelect = {
...registry,
Stack,
Select: ValidatedSelect,
};
it("does not show 'required' error when selecting the first value", async () => {
const spec: Spec = {
state: { form: { country: "" } },
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["countrySelect"],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "US" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-country"), {
target: { value: "US" },
});
});
// The value should be set in state
const state = getState();
expect((state.form as Record<string, unknown>).country).toBe("US");
// No validation error should appear -- "US" is non-empty
expect(screen.queryByTestId("select-error-country")).toBeNull();
});
it("does not show 'required' error when selecting the first city after country change resets it", async () => {
const spec: Spec = {
state: {
form: { country: "US", city: "" },
availableCities: ["New York", "Chicago"],
},
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {},
children: ["citySelect"],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: ["New York", "Chicago"],
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
},
};
render(
<JSONUIProvider registry={regWithSelect} initialState={spec.state}>
<Renderer spec={spec} registry={regWithSelect} />
<StateProbe />
</JSONUIProvider>,
);
// Select "New York" for the first time (from empty)
await act(async () => {
fireEvent.change(screen.getByTestId("select-city"), {
target: { value: "New York" },
});
});
const state = getState();
expect((state.form as Record<string, unknown>).city).toBe("New York");
// No validation error should appear
expect(screen.queryByTestId("select-error-city")).toBeNull();
});
});
+1
View File
@@ -30,6 +30,7 @@ export {
export { export {
ValidationProvider, ValidationProvider,
useValidation, useValidation,
useOptionalValidation,
useFieldValidation, useFieldValidation,
type ValidationContextValue, type ValidationContextValue,
type ValidationProviderProps, type ValidationProviderProps,
+118 -12
View File
@@ -5,7 +5,9 @@ import React, {
type ErrorInfo, type ErrorInfo,
type ReactNode, type ReactNode,
useCallback, useCallback,
useEffect,
useMemo, useMemo,
useRef,
} from "react"; } from "react";
import type { import type {
UIElement, UIElement,
@@ -14,6 +16,7 @@ import type {
Catalog, Catalog,
SchemaDefinition, SchemaDefinition,
StateStore, StateStore,
ComputedFunction,
} from "@json-render/core"; } from "@json-render/core";
import { import {
resolveElementProps, resolveElementProps,
@@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component<
} }
} }
// ---------------------------------------------------------------------------
// FunctionsContext – provides $computed functions to the element tree
// ---------------------------------------------------------------------------
const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
const FunctionsContext =
React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
function useFunctions(): Record<string, ComputedFunction> {
return React.useContext(FunctionsContext);
}
interface ElementRendererProps { interface ElementRendererProps {
element: UIElement; element: UIElement;
spec: Spec; spec: Spec;
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
const repeatScope = useRepeatScope(); const repeatScope = useRepeatScope();
const { ctx } = useVisibility(); const { ctx } = useVisibility();
const { execute } = useActions(); const { execute } = useActions();
const { getSnapshot } = useStateStore(); const { getSnapshot, state: watchState } = useStateStore();
const functions = useFunctions();
// Build context with repeat scope (used for both visibility and props) // Build context with repeat scope and $computed functions
const fullCtx: PropResolutionContext = useMemo( const fullCtx: PropResolutionContext = useMemo(() => {
() => const base: PropResolutionContext = repeatScope
repeatScope
? { ? {
...ctx, ...ctx,
repeatItem: repeatScope.item, repeatItem: repeatScope.item,
repeatIndex: repeatScope.index, repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath, repeatBasePath: repeatScope.basePath,
} }
: ctx, : { ...ctx };
[ctx, repeatScope], base.functions = functions;
); return base;
}, [ctx, repeatScope, functions]);
// Evaluate visibility (now supports $item/$index inside repeat scopes) // Evaluate visibility (now supports $item/$index inside repeat scopes)
const isVisible = const isVisible =
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
[onBindings, emit], [onBindings, emit],
); );
// Watch effect: fire actions when watched state paths change.
// Must be called before any early return to satisfy Rules of Hooks.
//
// Two refs serve distinct roles:
// - `stableWatchRef` (useMemo): holds the last emitted values object so we
// can return the same reference when watched values haven't changed,
// preventing the downstream useEffect from firing on unrelated state updates.
// - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
// for change detection. Starts as `null` to skip the initial mount.
const watchConfig = element.watch;
const prevWatchValues = useRef<Record<string, unknown> | null>(null);
const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
const watchedValues = useMemo(() => {
if (!watchConfig) return undefined;
const values: Record<string, unknown> = {};
for (const path of Object.keys(watchConfig)) {
values[path] = getByPath(watchState, path);
}
const prev = stableWatchRef.current;
if (prev) {
const keys = Object.keys(values);
if (
keys.length === Object.keys(prev).length &&
keys.every((k) => values[k] === prev[k])
) {
return prev;
}
}
stableWatchRef.current = values;
return values;
}, [watchConfig, watchState]);
useEffect(() => {
if (!watchConfig || !watchedValues) return;
const paths = Object.keys(watchConfig);
if (paths.length === 0) return;
const prev = prevWatchValues.current;
prevWatchValues.current = watchedValues;
// Skip the initial mount — only fire on changes
if (prev === null) return;
let cancelled = false;
void (async () => {
for (const path of paths) {
if (cancelled) break;
if (watchedValues[path] !== prev[path]) {
const binding = watchConfig[path];
if (!binding) continue;
const bindings = Array.isArray(binding) ? binding : [binding];
for (const b of bindings) {
if (cancelled) break;
if (!b.params) {
await execute(b);
if (cancelled) break;
continue;
}
const liveCtx: PropResolutionContext = {
...fullCtx,
stateModel: getSnapshot(),
};
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, liveCtx);
}
await execute({ ...b, params: resolved });
if (cancelled) break;
}
}
}
})().catch(console.error);
return () => {
cancelled = true;
};
}, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
// Don't render if not visible // Don't render if not visible
if (!isVisible) { if (!isVisible) {
return null; return null;
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
string, string,
(value: unknown, args?: Record<string, unknown>) => boolean (value: unknown, args?: Record<string, unknown>) => boolean
>; >;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Callback when state changes (uncontrolled mode) */ /** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
children: ReactNode; children: ReactNode;
@@ -434,6 +532,7 @@ export function JSONUIProvider({
handlers, handlers,
navigate, navigate,
validationFunctions, validationFunctions,
functions,
onStateChange, onStateChange,
children, children,
}: JSONUIProviderProps) { }: JSONUIProviderProps) {
@@ -444,12 +543,14 @@ export function JSONUIProvider({
onStateChange={onStateChange} onStateChange={onStateChange}
> >
<VisibilityProvider> <VisibilityProvider>
<ActionProvider handlers={handlers} navigate={navigate}>
<ValidationProvider customFunctions={validationFunctions}> <ValidationProvider customFunctions={validationFunctions}>
<ActionProvider handlers={handlers} navigate={navigate}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
{children} {children}
<ConfirmationDialogManager /> <ConfirmationDialogManager />
</ValidationProvider> </FunctionsContext.Provider>
</ActionProvider> </ActionProvider>
</ValidationProvider>
</VisibilityProvider> </VisibilityProvider>
</StateProvider> </StateProvider>
); );
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
onAction?: (actionName: string, params?: Record<string, unknown>) => void; onAction?: (actionName: string, params?: Record<string, unknown>) => void;
/** Callback when state changes (uncontrolled mode) */ /** Callback when state changes (uncontrolled mode) */
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void; onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
/** Named functions for `$computed` expressions in props */
functions?: Record<string, ComputedFunction>;
/** Whether the spec is currently loading/streaming */ /** Whether the spec is currently loading/streaming */
loading?: boolean; loading?: boolean;
/** Fallback component for unknown types */ /** Fallback component for unknown types */
@@ -703,6 +806,7 @@ export function createRenderer<
state, state,
onAction, onAction,
onStateChange, onStateChange,
functions,
loading, loading,
fallback, fallback,
}: CreateRendererProps) { }: CreateRendererProps) {
@@ -730,8 +834,9 @@ export function createRenderer<
onStateChange={onStateChange} onStateChange={onStateChange}
> >
<VisibilityProvider> <VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider> <ValidationProvider>
<ActionProvider handlers={actionHandlers}>
<FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
<Renderer <Renderer
spec={spec} spec={spec}
registry={registry} registry={registry}
@@ -739,8 +844,9 @@ export function createRenderer<
fallback={fallback} fallback={fallback}
/> />
<ConfirmationDialogManager /> <ConfirmationDialogManager />
</ValidationProvider> </FunctionsContext.Provider>
</ActionProvider> </ActionProvider>
</ValidationProvider>
</VisibilityProvider> </VisibilityProvider>
</StateProvider> </StateProvider>
); );
+5
View File
@@ -67,6 +67,11 @@ export const schema = defineSchema(
description: description:
"Remove an item from an array in state by index. Params: { statePath: string, index: number }", "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
}, },
{
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[]> }.",
},
], ],
defaultRules: [ defaultRules: [
// Element integrity // Element integrity
+2 -1
View File
@@ -3,8 +3,9 @@ import { defineConfig } from "tsup";
export default defineConfig({ export default defineConfig({
entry: ["src/index.ts", "src/schema.ts"], entry: ["src/index.ts", "src/schema.ts"],
format: ["cjs", "esm"], format: ["cjs", "esm"],
dts: true, dts: { resolve: ["@internal/react-state"] },
sourcemap: true, sourcemap: true,
clean: true, clean: true,
noExternal: ["@internal/react-state"],
external: ["react", "react-dom", "@json-render/core"], external: ["react", "react-dom", "@json-render/core"],
}); });
+13
View File
@@ -1,5 +1,18 @@
# @json-render/redux # @json-render/redux
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
- @json-render/core@0.9.1
## 0.9.0 ## 0.9.0
### Minor Changes ### Minor Changes

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