Chris Tate c731a9c607 Fix visible validation depending on consumer zod version (#299) 1 mese fa
..
src c731a9c607 Fix visible validation depending on consumer zod version (#299) 1 mese fa
CHANGELOG.md 9adcc09204 chore: version packages (#248) 3 mesi fa
README.md 3c11f19be4 vue improvements (#163) 4 mesi fa
package.json 838ee7bf00 prepare v0.19.0 (#282) 2 mesi fa
tsconfig.json db3a8b41e9 feat: add Vue renderer (#162) 4 mesi fa
tsup.config.ts db3a8b41e9 feat: add Vue renderer (#162) 4 mesi fa

README.md

@json-render/vue

Vue 3 renderer for json-render. Turn JSON specs into Vue components with data binding, visibility, and actions.

Installation

npm install @json-render/vue @json-render/core zod

Peer dependencies: vue ^3.5.0 and zod ^4.0.0.

Quick Start

1. Create a Catalog

import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { z } from "zod";

export const catalog = defineCatalog(schema, {
  components: {
    Card: {
      props: z.object({
        title: z.string(),
        description: z.string().nullable(),
      }),
      description: "A card container",
    },
    Button: {
      props: z.object({
        label: z.string(),
        action: z.string(),
      }),
      description: "A clickable button",
    },
    Input: {
      props: z.object({
        value: z.union([z.string(), z.record(z.unknown())]).nullable(),
        label: z.string(),
        placeholder: z.string().nullable(),
      }),
      description: "Text input field with optional value binding",
    },
  },
  actions: {
    submit: { description: "Submit the form" },
    cancel: { description: "Cancel and close" },
  },
});

2. Define Component Implementations

Components are written using Vue's h() render function. children is a VNode | VNode[] — pass it directly to your container element.

defineRegistry conditionally requires the actions field only when the catalog declares actions. Catalogs with actions: {} can omit it entirely.

import { h } from "vue";
import { defineRegistry } from "@json-render/vue";
import { catalog } from "./catalog";

export const { registry } = defineRegistry(catalog, {
  components: {
    Card: ({ props, children }) =>
      h("div", { class: "card" }, [
        h("h3", null, props.title),
        props.description ? h("p", null, props.description) : null,
        children,
      ]),
    Button: ({ props, emit }) =>
      h("button", { onClick: () => emit("press") }, props.label),
    Input: ({ props, bindings }) => {
      // Use bindings?.value with a watcher to implement two-way binding
      return h("label", null, [
        props.label,
        h("input", {
          placeholder: props.placeholder ?? "",
          value: props.value ?? "",
        }),
      ]);
    },
  },
  // actions stubs are required when the catalog declares actions:
  actions: {
    submit: async () => {},
    cancel: async () => {},
  },
});

Tip: Use useBoundProp(props.value, bindings?.value) for two-way binding, or handle the bindings object directly in your component.

3. Render Specs

<script setup lang="ts">
import { StateProvider, ActionProvider, Renderer } from "@json-render/vue";
import { registry } from "./registry";

const spec = { /* ... */ };

function handleSubmit(params) {
  console.log("Submit", params);
}
</script>

<template>
  <StateProvider :initial-state="{ form: { name: '' } }">
    <ActionProvider :handlers="{ submit: handleSubmit }">
      <Renderer :spec="spec" :registry="registry" />
    </ActionProvider>
  </StateProvider>
</template>

Spec Format

The Vue renderer uses the same flat element map format as the React renderer:

interface Spec {
  root: string;                          // Key of the root element
  elements: Record<string, UIElement>;   // Flat map of elements by key
  state?: Record<string, unknown>;       // Optional initial state
}

interface UIElement {
  type: string;                          // Component name from catalog
  props: Record<string, unknown>;        // Component props
  children?: string[];                   // Keys of child elements
  visible?: VisibilityCondition;         // Visibility condition
}

Example spec:

{
  "root": "card-1",
  "elements": {
    "card-1": {
      "type": "Card",
      "props": { "title": "Welcome" },
      "children": ["input-1", "btn-1"]
    },
    "input-1": {
      "type": "Input",
      "props": {
        "value": { "$bindState": "/form/name" },
        "label": "Name",
        "placeholder": "Enter name"
      }
    },
    "btn-1": {
      "type": "Button",
      "props": { "label": "Submit" },
      "children": []
    }
  }
}

Providers

StateProvider

Share data across components with JSON Pointer paths:

<StateProvider :initial-state="{ user: { name: 'John' } }">
  <!-- children -->
</StateProvider>
// In composables:
const { state, get, set } = useStateStore();
const name = get("/user/name");  // "John"
set("/user/age", 25);

// state is a ShallowRef<StateModel> — access with state.value
console.log(state.value);

External Store (Controlled Mode)

Pass a StateStore to bypass the internal state and wire json-render to any state management library (Pinia, VueUse, etc.):

import { createStateStore, type StateStore } from "@json-render/vue";

// Option 1: Use the built-in store outside of Vue
const store = createStateStore({ count: 0 });
<StateProvider :store="store">
  <!-- children -->
</StateProvider>
// Mutate from anywhere — Vue will re-render automatically:
store.set("/count", 1);

// Option 2: Implement the StateStore interface with your own backend
const piniaStore: StateStore = {
  get: (path) => getByPath(myStore.$state, path),
  set: (path, value) => myStore.$patch(/* ... */),
  update: (updates) => myStore.$patch(/* ... */),
  getSnapshot: () => myStore.$state,
  subscribe: (listener) => myStore.$subscribe(listener),
};

When store is provided, initialState and onStateChange are ignored.

ActionProvider

Handle actions from components:

<ActionProvider :handlers="{ submit: handleSubmit, cancel: handleCancel }">
  <!-- children -->
</ActionProvider>

VisibilityProvider

Control element visibility based on data:

<VisibilityProvider>
  <!-- children -->
</VisibilityProvider>
{
  "type": "Alert",
  "props": { "message": "Error!" },
  "visible": { "$state": "/form/hasError" }
}

ValidationProvider

Add field validation:

<ValidationProvider>
  <!-- children -->
</ValidationProvider>
// Use validation composable:
const { errors, validate } = useFieldValidation("/form/email", {
  checks: [
    { type: "required", message: "Email required" },
    { type: "email", message: "Invalid email" },
  ],
});

Composables

Composable Purpose
useStateStore() Access state context (state as ShallowRef, get, set, update)
useStateValue(path) Get single value from state
useStateBinding(path) Two-way data binding (deprecated — use $bindState instead)
useIsVisible(condition) Check if a visibility condition is met
useActions() Access action context
useAction(binding) Get a single action dispatch function
useFieldValidation(path, config) Field validation state

Note: useStateStore().state returns a ShallowRef<StateModel> — use state.value to access the underlying object. This differs from the React renderer where state is a plain object.

Visibility Conditions

// Truthiness check
{ "$state": "/user/isAdmin" }

// Auth state (use state path)
{ "$state": "/auth/isSignedIn" }

// Comparisons (flat style)
{ "$state": "/status", "eq": "active" }
{ "$state": "/count", "gt": 10 }

// Negation
{ "$state": "/maintenance", "not": true }

// Multiple conditions (implicit AND)
[
  { "$state": "/feature/enabled" },
  { "$state": "/maintenance", "not": true }
]

// Always / never
true   // always visible
false  // never visible

TypeScript helpers from @json-render/core:

import { visibility } from "@json-render/core";

visibility.when("/path")       // { $state: "/path" }
visibility.unless("/path")     // { $state: "/path", not: true }
visibility.eq("/path", val)    // { $state: "/path", eq: val }
visibility.neq("/path", val)   // { $state: "/path", neq: val }
visibility.and(cond1, cond2)  // { $and: [cond1, cond2] }
visibility.always             // true
visibility.never              // false

Dynamic Prop Expressions

Any prop value can use data-driven expressions that resolve at render time. The renderer resolves these transparently before passing props to components.

{
  "type": "Badge",
  "props": {
    "label": { "$state": "/user/role" },
    "color": {
      "$cond": { "$state": "/user/role", "eq": "admin" },
      "$then": "red",
      "$else": "gray"
    }
  }
}

For two-way binding, use { "$bindState": "/path" } on the natural value prop. Inside repeat scopes, use { "$bindItem": "field" } instead. Components receive resolved bindings with the state path for each bound prop.

See @json-render/core for full expression syntax.

Built-in Actions

The setState, pushState, removeState, and validateForm actions are built into the Vue schema and handled automatically by ActionProvider. They are injected into AI prompts without needing to be declared in your catalog's actions. They update the state model, which triggers re-evaluation of visibility conditions and dynamic prop expressions:

{
  "type": "Button",
  "props": { "label": "Switch Tab" },
  "on": {
    "press": {
      "action": "setState",
      "params": { "statePath": "/activeTab", "value": "settings" }
    }
  },
  "children": []
}

Component Props

When using defineRegistry, components receive these props via their render function:

import type { VNode } from "vue";

interface ComponentContext<P> {
  props: P;                          // Typed props from the catalog (expressions resolved)
  children?: VNode | VNode[];        // Rendered children (for container components)
  emit: (event: string) => void;     // Emit a named event (always defined)
  on: (event: string) => EventHandle; // Get event handle with metadata
  loading?: boolean;                 // Whether the parent is loading
  bindings?: Record<string, string>; // State paths for $bindState/$bindItem expressions
}

interface EventHandle {
  emit: () => void;            // Fire the event
  shouldPreventDefault: boolean; // Whether any binding requested preventDefault
  bound: boolean;              // Whether any handler is bound
}

Use emit("press") for simple event firing. Use on("click") when you need to check metadata like shouldPreventDefault or bound:

Link: ({ props, on }) => {
  const click = on("click");
  return h("a", {
    href: props.href,
    onClick: (e: MouseEvent) => {
      if (click.shouldPreventDefault) e.preventDefault();
      click.emit();
    },
  }, props.label);
},

BaseComponentProps

For building reusable component libraries that are not tied to a specific catalog, use the catalog-agnostic BaseComponentProps type:

import type { BaseComponentProps } from "@json-render/vue";

const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
  h("div", null, [props.title, children]);

Generate AI Prompts

const systemPrompt = catalog.prompt();
// Returns detailed prompt with component/action descriptions

Full Example

import { h } from "vue";
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/vue/schema";
import { defineRegistry, Renderer, StateProvider } from "@json-render/vue";
import { z } from "zod";

const catalog = defineCatalog(schema, {
  components: {
    Greeting: {
      props: z.object({ name: z.string() }),
      description: "Displays a greeting",
    },
  },
  actions: {},
});

const { registry } = defineRegistry(catalog, {
  components: {
    Greeting: ({ props }) => h("h1", null, `Hello, ${props.name}!`),
  },
});

const spec = {
  root: "greeting-1",
  elements: {
    "greeting-1": {
      type: "Greeting",
      props: { name: "World" },
      children: [],
    },
  },
};

// In your App.vue:
// <StateProvider>
//   <Renderer :spec="spec" :registry="registry" />
// </StateProvider>

Key Exports

Export Purpose
defineRegistry Create a type-safe component registry from a catalog
Renderer Render a spec using a registry
schema Element tree schema (includes built-in actions: setState, pushState, removeState)
useStateStore Access state context (state is ShallowRef<StateModel>)
useStateValue Get single value from state
useActions Access actions context
useAction Get a single action dispatch function
createStateStore Create a framework-agnostic in-memory StateStore

Types

Export Purpose
ComponentContext Typed component render function context (catalog-aware)
BaseComponentProps Catalog-agnostic base type for reusable component libraries
EventHandle Event handle with emit(), shouldPreventDefault, bound
ActionProviderProps Props for ActionProvider
ValidationProviderProps Props for ValidationProvider
ComponentFn Component render function type
SetState State setter type
StateModel State model type
StateStore Interface for plugging in external state management

Differences from @json-render/react

API React Vue Note
useStateStore().state StateModel ShallowRef<StateModel> Vue reactivity; use state.value
children type React.ReactNode VNode \| VNode[] Platform-specific
useBoundProp exported exported Same API; returns [value, setValue]
Streaming hooks useUIStream, useChatUI useUIStream, useChatUI Same API; returns Vue Ref values