mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9d484f985 | ||
|
|
d15a287645 | ||
|
|
3259c0ac85 | ||
|
|
cb18dc5e88 | ||
|
|
e07a5a02f1 | ||
|
|
e42fe26064 | ||
|
|
df6316d076 | ||
|
|
9f58d8712c | ||
|
|
c2b397510e | ||
|
|
8506cfaa03 | ||
|
|
9cef4e9142 | ||
|
|
3c11f19be4 | ||
|
|
db3a8b41e9 | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d | ||
|
|
0b7d767cdd | ||
|
|
b1036763d2 |
@@ -13,7 +13,9 @@
|
||||
"@json-render/codegen",
|
||||
"@json-render/zustand",
|
||||
"@json-render/redux",
|
||||
"@json-render/jotai"
|
||||
"@json-render/jotai",
|
||||
"@json-render/vue",
|
||||
"@json-render/xstate"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -26,6 +26,7 @@ This ensures we don't install outdated versions that may have incompatible types
|
||||
|
||||
- Do not use emojis in code or UI
|
||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
||||
- **Web app docs (`apps/web/`):** Never use Markdown table syntax (`| col | col |`). Always use HTML `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`. Markdown tables do not render correctly in the web app. Inside HTML table cells, curly braces must be escaped as JSX expressions (e.g. `<code>{'{ "$state": "/path" }'}</code>`) because MDX parses `{` as a JSX expression boundary.
|
||||
|
||||
## AI SDK / AI Gateway
|
||||
|
||||
|
||||
@@ -5,15 +5,18 @@
|
||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
# for React
|
||||
npm install @json-render/core @json-render/react
|
||||
# pre-built shadcn/ui components
|
||||
# for React with pre-built shadcn/ui components
|
||||
npm install @json-render/shadcn
|
||||
# or for mobile
|
||||
# or for React Native
|
||||
npm install @json-render/core @json-render/react-native
|
||||
# or for video
|
||||
npm install @json-render/core @json-render/remotion
|
||||
# or for PDF documents
|
||||
npm install @json-render/core @json-render/react-pdf
|
||||
# or for Vue
|
||||
npm install @json-render/core @json-render/vue
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
@@ -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
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **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
|
||||
|
||||
## Quick Start
|
||||
@@ -110,6 +113,7 @@ function Dashboard({ spec }) {
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/react` | React renderer, contexts, hooks |
|
||||
| `@json-render/vue` | Vue 3 renderer, composables, providers |
|
||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
@@ -117,6 +121,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||
|
||||
## Renderers
|
||||
|
||||
@@ -148,6 +153,26 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### Vue (UI)
|
||||
|
||||
```typescript
|
||||
import { h } from "vue";
|
||||
import { defineRegistry, Renderer } from "@json-render/vue";
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
Button: ({ props, emit }) =>
|
||||
h("button", { onClick: () => emit("press") }, props.label),
|
||||
},
|
||||
});
|
||||
|
||||
// In your Vue component template:
|
||||
// <Renderer :spec="spec" :registry="registry" />
|
||||
```
|
||||
|
||||
### shadcn/ui (Web)
|
||||
|
||||
```tsx
|
||||
@@ -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
|
||||
- **`{ "$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
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
@@ -351,6 +394,8 @@ pnpm dev
|
||||
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
|
||||
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
|
||||
- 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`
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -9,54 +9,79 @@ React Native renderer with standard components, providers, and hooks.
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Container` | `padding`, `background`, `borderRadius`, `borderColor`, `flex` | Basic wrapper with styling |
|
||||
| `Row` | `gap`, `align`, `justify`, `flex`, `wrap` | Horizontal flex layout |
|
||||
| `Column` | `gap`, `align`, `justify`, `flex` | Vertical flex layout |
|
||||
| `ScrollContainer` | `direction` | Scrollable area (vertical or horizontal) |
|
||||
| `SafeArea` | `edges` | Safe area insets for notch/home indicator |
|
||||
| `Pressable` | `action`, `actionParams` | Touchable wrapper that triggers actions |
|
||||
| `Spacer` | `size`, `flex` | Fixed or flexible spacing |
|
||||
| `Divider` | `color`, `thickness` | Thin line separator |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Container</code></td><td><code>padding</code>, <code>background</code>, <code>borderRadius</code>, <code>borderColor</code>, <code>flex</code></td><td>Basic wrapper with styling</td></tr>
|
||||
<tr><td><code>Row</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code>, <code>wrap</code></td><td>Horizontal flex layout</td></tr>
|
||||
<tr><td><code>Column</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code></td><td>Vertical flex layout</td></tr>
|
||||
<tr><td><code>ScrollContainer</code></td><td><code>direction</code></td><td>Scrollable area (vertical or horizontal)</td></tr>
|
||||
<tr><td><code>SafeArea</code></td><td><code>edges</code></td><td>Safe area insets for notch/home indicator</td></tr>
|
||||
<tr><td><code>Pressable</code></td><td><code>action</code>, <code>actionParams</code></td><td>Touchable wrapper that triggers actions</td></tr>
|
||||
<tr><td><code>Spacer</code></td><td><code>size</code>, <code>flex</code></td><td>Fixed or flexible spacing</td></tr>
|
||||
<tr><td><code>Divider</code></td><td><code>color</code>, <code>thickness</code></td><td>Thin line separator</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Heading` | `text`, `level`, `align`, `color` | Heading text (levels 1-6) |
|
||||
| `Paragraph` | `text`, `align`, `color` | Body text |
|
||||
| `Label` | `text`, `color`, `bold` | Small label text |
|
||||
| `Image` | `uri`, `width`, `height`, `resizeMode`, `borderRadius` | Image display |
|
||||
| `Avatar` | `uri`, `size`, `fallback` | Circular avatar |
|
||||
| `Badge` | `label`, `color`, `textColor` | Status badge |
|
||||
| `Chip` | `label`, `selected`, `color` | Tag/chip |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code>, <code>align</code>, <code>color</code></td><td>Heading text (levels 1-6)</td></tr>
|
||||
<tr><td><code>Paragraph</code></td><td><code>text</code>, <code>align</code>, <code>color</code></td><td>Body text</td></tr>
|
||||
<tr><td><code>Label</code></td><td><code>text</code>, <code>color</code>, <code>bold</code></td><td>Small label text</td></tr>
|
||||
<tr><td><code>Image</code></td><td><code>uri</code>, <code>width</code>, <code>height</code>, <code>resizeMode</code>, <code>borderRadius</code></td><td>Image display</td></tr>
|
||||
<tr><td><code>Avatar</code></td><td><code>uri</code>, <code>size</code>, <code>fallback</code></td><td>Circular avatar</td></tr>
|
||||
<tr><td><code>Badge</code></td><td><code>label</code>, <code>color</code>, <code>textColor</code></td><td>Status badge</td></tr>
|
||||
<tr><td><code>Chip</code></td><td><code>label</code>, <code>selected</code>, <code>color</code></td><td>Tag/chip</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Input
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
|
||||
| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
|
||||
| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
|
||||
| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Button</code></td><td><code>label</code>, <code>variant</code>, <code>size</code>, <code>disabled</code>, <code>action</code>, <code>actionParams</code></td><td>Pressable button</td></tr>
|
||||
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>secure</code>, <code>keyboardType</code>, <code>multiline</code></td><td>Text input field</td></tr>
|
||||
<tr><td><code>Switch</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Toggle switch</td></tr>
|
||||
<tr><td><code>Checkbox</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Checkbox with label</td></tr>
|
||||
<tr><td><code>Slider</code></td><td><code>value</code> (use <code>$bindState</code>), <code>min</code>, <code>max</code>, <code>step</code></td><td>Range slider</td></tr>
|
||||
<tr><td><code>SearchBar</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>)</td><td>Search input</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Feedback
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Spinner` | `size`, `color` | Loading indicator |
|
||||
| `ProgressBar` | `progress`, `color`, `trackColor` | Progress indicator |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Spinner</code></td><td><code>size</code>, <code>color</code></td><td>Loading indicator</td></tr>
|
||||
<tr><td><code>ProgressBar</code></td><td><code>progress</code>, <code>color</code>, <code>trackColor</code></td><td>Progress indicator</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Composite
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Card` | `title`, `subtitle`, `padding` | Card container |
|
||||
| `ListItem` | `title`, `subtitle`, `leading`, `trailing`, `action`, `actionParams` | List row |
|
||||
| `Modal` | `visible`, `title` | Bottom sheet modal |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>Card</code></td><td><code>title</code>, <code>subtitle</code>, <code>padding</code></td><td>Card container</td></tr>
|
||||
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code>, <code>action</code>, <code>actionParams</code></td><td>List row</td></tr>
|
||||
<tr><td><code>Modal</code></td><td><code>visible</code>, <code>title</code></td><td>Bottom sheet modal</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Providers
|
||||
|
||||
@@ -68,11 +93,16 @@ React Native renderer with standard components, providers, and hooks.
|
||||
</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. |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
|
||||
<tr><td><code>initialState</code></td><td><code>Record<string, unknown></code></td><td>Initial state model (uncontrolled mode).</td></tr>
|
||||
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
@@ -190,8 +220,13 @@ import { standardComponentDefinitions, standardActionDefinitions } from "@json-r
|
||||
import { schema } from "@json-render/react-native/schema";
|
||||
```
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `standardComponentDefinitions` | Catalog definitions for all 25+ standard components |
|
||||
| `standardActionDefinitions` | Catalog definitions for standard actions (setState, navigate) |
|
||||
| `schema` | React Native element tree schema |
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Export</th><th>Purpose</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 25+ standard components</td></tr>
|
||||
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, navigate)</td></tr>
|
||||
<tr><td><code>schema</code></td><td>React Native element tree schema</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -15,11 +15,32 @@ React components, providers, and hooks.
|
||||
</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. |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prop</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>store</code></td>
|
||||
<td><code>StateStore</code></td>
|
||||
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>initialState</code></td>
|
||||
<td><code>Record<string, unknown></code></td>
|
||||
<td>Initial state model (uncontrolled mode).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>onStateChange</code></td>
|
||||
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
|
||||
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### External Store (Controlled Mode)
|
||||
|
||||
@@ -109,6 +130,40 @@ const { registry } = defineRegistry(catalog, {
|
||||
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||
```
|
||||
|
||||
### JSONUIProvider
|
||||
|
||||
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prop</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>functions</code></td>
|
||||
<td><code>Record<string, ComputedFunction></code></td>
|
||||
<td>Named functions for <code>$computed</code> expressions in props</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
```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)
|
||||
|
||||
```tsx
|
||||
@@ -237,6 +292,15 @@ const {
|
||||
|
||||
`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
|
||||
|
||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||
|
||||
@@ -15,10 +15,27 @@ Your app must have Tailwind CSS configured.
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Entry Point | Exports | Use For |
|
||||
|-------------|---------|---------|
|
||||
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
|
||||
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Entry Point</th>
|
||||
<th>Exports</th>
|
||||
<th>Use For</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>@json-render/shadcn</code></td>
|
||||
<td><code>shadcnComponents</code></td>
|
||||
<td>React implementations</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/shadcn/catalog</code></td>
|
||||
<td><code>shadcnComponentDefinitions</code></td>
|
||||
<td>Catalog schemas (no React dependency, safe for server)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -103,69 +120,225 @@ const { registry } = defineRegistry(catalog, {
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Card` | Container card with optional title, description, maxWidth, centered |
|
||||
| `Stack` | Flex container with direction, gap, align, justify |
|
||||
| `Grid` | Grid layout with columns (1-6) and gap |
|
||||
| `Separator` | Visual separator line with orientation |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Card</code></td>
|
||||
<td>Container card with optional title, description, maxWidth, centered</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Stack</code></td>
|
||||
<td>Flex container with direction, gap, align, justify</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Grid</code></td>
|
||||
<td>Grid layout with columns (1-6) and gap</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Separator</code></td>
|
||||
<td>Visual separator line with orientation</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Navigation
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
|
||||
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
|
||||
| `Collapsible` | Single collapsible section with title and defaultOpen |
|
||||
| `Pagination` | Page navigation with totalPages and page |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Tabs</code></td>
|
||||
<td>Tabbed navigation with tabs array, defaultValue, value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Accordion</code></td>
|
||||
<td>Collapsible sections with items array and type (single/multiple)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Collapsible</code></td>
|
||||
<td>Single collapsible section with title and defaultOpen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Pagination</code></td>
|
||||
<td>Page navigation with totalPages and page</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Overlay
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Dialog` | Modal dialog with title, description, openPath |
|
||||
| `Drawer` | Bottom drawer with title, description, openPath |
|
||||
| `Tooltip` | Hover tooltip with content and text |
|
||||
| `Popover` | Click-triggered popover with trigger and content |
|
||||
| `DropdownMenu` | Dropdown menu with label and items array |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Dialog</code></td>
|
||||
<td>Modal dialog with title, description, openPath</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Drawer</code></td>
|
||||
<td>Bottom drawer with title, description, openPath</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Tooltip</code></td>
|
||||
<td>Hover tooltip with content and text</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Popover</code></td>
|
||||
<td>Click-triggered popover with trigger and content</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>DropdownMenu</code></td>
|
||||
<td>Dropdown menu with label and items array</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Heading` | Heading text with level (h1-h4) |
|
||||
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
|
||||
| `Image` | Image with alt, width, height |
|
||||
| `Avatar` | User avatar with src, name, size |
|
||||
| `Badge` | Status badge with text and variant |
|
||||
| `Alert` | Alert banner with title, message, type |
|
||||
| `Carousel` | Horizontally scrollable carousel with items |
|
||||
| `Table` | Data table with columns and rows |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Heading</code></td>
|
||||
<td>Heading text with level (h1-h4)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Text</code></td>
|
||||
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Image</code></td>
|
||||
<td>Image with alt, width, height</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Avatar</code></td>
|
||||
<td>User avatar with src, name, size</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Badge</code></td>
|
||||
<td>Status badge with text and variant</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Alert</code></td>
|
||||
<td>Alert banner with title, message, type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Carousel</code></td>
|
||||
<td>Horizontally scrollable carousel with items</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Table</code></td>
|
||||
<td>Data table with columns and rows</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Feedback
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Progress` | Progress bar with value, max, label |
|
||||
| `Skeleton` | Loading placeholder with width, height, rounded |
|
||||
| `Spinner` | Loading spinner with size and label |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Progress</code></td>
|
||||
<td>Progress bar with value, max, label</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Skeleton</code></td>
|
||||
<td>Loading placeholder with width, height, rounded</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Spinner</code></td>
|
||||
<td>Loading spinner with size and label</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Input
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Button` | Clickable button with label, variant, disabled |
|
||||
| `Link` | Anchor link with label and href |
|
||||
| `Input` | Text input with label, name, type, placeholder, value, checks |
|
||||
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
|
||||
| `Select` | Dropdown select with label, name, options, value, checks |
|
||||
| `Checkbox` | Checkbox with label, name, checked |
|
||||
| `Radio` | Radio button group with label, name, options, value |
|
||||
| `Switch` | Toggle switch with label, name, checked |
|
||||
| `Slider` | Range slider with label, min, max, step, value |
|
||||
| `Toggle` | Toggle button with label, pressed, variant |
|
||||
| `ToggleGroup` | Group of toggle buttons with items, type, value |
|
||||
| `ButtonGroup` | Group of buttons with buttons array and selected |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Component</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Button</code></td>
|
||||
<td>Clickable button with label, variant, disabled</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Link</code></td>
|
||||
<td>Anchor link with label and href</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Input</code></td>
|
||||
<td>Text input with label, name, type, placeholder, value, checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Textarea</code></td>
|
||||
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Select</code></td>
|
||||
<td>Dropdown select with label, name, options, value, checks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Checkbox</code></td>
|
||||
<td>Checkbox with label, name, checked</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Radio</code></td>
|
||||
<td>Radio button group with label, name, options, value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Switch</code></td>
|
||||
<td>Toggle switch with label, name, checked</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Slider</code></td>
|
||||
<td>Range slider with label, min, max, step, value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>Toggle</code></td>
|
||||
<td>Toggle button with label, pressed, variant</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ToggleGroup</code></td>
|
||||
<td>Group of toggle buttons with items, type, value</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ButtonGroup</code></td>
|
||||
<td>Group of buttons with buttons array and selected</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
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>
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Prop</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>store</code></td>
|
||||
<td><code>StateStore</code></td>
|
||||
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>initialState</code></td>
|
||||
<td><code>Record<string, unknown></code></td>
|
||||
<td>Initial state model (uncontrolled mode).</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>onStateChange</code></td>
|
||||
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
|
||||
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
#### 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`
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>API</th>
|
||||
<th>React</th>
|
||||
<th>Vue</th>
|
||||
<th>Note</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>useStateStore().state</code></td>
|
||||
<td><code>StateModel</code> (plain object)</td>
|
||||
<td><code>ShallowRef<StateModel></code></td>
|
||||
<td>Vue reactivity; use <code>state.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useStateValue()</code></td>
|
||||
<td><code>T | undefined</code></td>
|
||||
<td><code>ComputedRef<T | undefined></code></td>
|
||||
<td>Vue reactivity; use <code>.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useStateBinding()</code></td>
|
||||
<td><code>[T | undefined, setter]</code></td>
|
||||
<td><code>[ComputedRef<T | undefined>, setter]</code></td>
|
||||
<td>Vue reactivity; use <code>value.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useAction().isLoading</code></td>
|
||||
<td><code>boolean</code></td>
|
||||
<td><code>ComputedRef<boolean></code></td>
|
||||
<td>Vue reactivity; use <code>.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useFieldValidation().state</code></td>
|
||||
<td><code>FieldValidationState</code></td>
|
||||
<td><code>ComputedRef<FieldValidationState></code></td>
|
||||
<td>Vue reactivity; use <code>.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useFieldValidation().errors</code></td>
|
||||
<td><code>string[]</code></td>
|
||||
<td><code>ComputedRef<string[]></code></td>
|
||||
<td>Vue reactivity; use <code>.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useFieldValidation().isValid</code></td>
|
||||
<td><code>boolean</code></td>
|
||||
<td><code>ComputedRef<boolean></code></td>
|
||||
<td>Vue reactivity; use <code>.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>VisibilityContextValue.ctx</code></td>
|
||||
<td><code>CoreVisibilityContext</code></td>
|
||||
<td><code>ComputedRef<CoreVisibilityContext></code></td>
|
||||
<td>Vue reactivity; use <code>ctx.value</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>children</code> type</td>
|
||||
<td><code>React.ReactNode</code></td>
|
||||
<td><code>VNode | VNode[]</code></td>
|
||||
<td>Platform-specific</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>useBoundProp</code></td>
|
||||
<td>exported</td>
|
||||
<td>exported</td>
|
||||
<td>Same API; returns <code>[value, setValue]</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>VisibilityProviderProps</code></td>
|
||||
<td>exported</td>
|
||||
<td>not exported (no props)</td>
|
||||
<td>Vue uses slot, no prop needed</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Streaming hooks</td>
|
||||
<td><code>useUIStream</code>, <code>useChatUI</code></td>
|
||||
<td><code>useUIStream</code>, <code>useChatUI</code></td>
|
||||
<td>Same API; returns Vue <code>Ref</code> values</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -5,6 +5,129 @@ export const metadata = pageMetadata("docs/changelog")
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
<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>Form components inside repeat</td>
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||
|
||||
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
|
||||
|
||||
<PackageInstall packages="react zod" />
|
||||
|
||||
## For Vue
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/vue" />
|
||||
|
||||
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
|
||||
|
||||
<PackageInstall packages="vue zod" />
|
||||
|
||||
## For React UI with shadcn/ui
|
||||
|
||||
Pre-built components for fast prototyping and production use:
|
||||
@@ -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/xstate" />
|
||||
|
||||
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||
|
||||
## Peer Dependencies
|
||||
|
||||
json-render requires the following peer dependencies:
|
||||
|
||||
- `react` ^19.0.0
|
||||
- `zod` ^4.0.0
|
||||
|
||||
<PackageInstall packages="react zod" />
|
||||
|
||||
## For AI Integration
|
||||
|
||||
To use json-render with AI models, you'll also need the Vercel AI SDK:
|
||||
|
||||
@@ -31,15 +31,23 @@ import { StateProvider } from "@json-render/react";
|
||||
|
||||
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `DataProvider` | `StateProvider` |
|
||||
| `data` prop | `initialState` prop |
|
||||
| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
|
||||
| `useData` | `useStateStore` |
|
||||
| `useDataValue` | `useStateValue` |
|
||||
| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
|
||||
| `DataModel` type | `StateModel` type |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Before</th>
|
||||
<th>After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>DataProvider</code></td><td><code>StateProvider</code></td></tr>
|
||||
<tr><td><code>data</code> prop</td><td><code>initialState</code> prop</td></tr>
|
||||
<tr><td><code>getValue</code> / <code>setValue</code> props</td><td>Removed (use <code>useStateStore()</code> hook for <code>get</code> / <code>set</code>)</td></tr>
|
||||
<tr><td><code>useData</code></td><td><code>useStateStore</code></td></tr>
|
||||
<tr><td><code>useDataValue</code></td><td><code>useStateValue</code></td></tr>
|
||||
<tr><td><code>useDataBinding</code></td><td><code>useStateBinding</code> (deprecated, use <code>useBoundProp</code> instead)</td></tr>
|
||||
<tr><td><code>DataModel</code> type</td><td><code>StateModel</code> type</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Dynamic Expressions
|
||||
|
||||
@@ -81,10 +89,18 @@ Inside repeat scopes, use `$item` and `$index`:
|
||||
}
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
|
||||
| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Before</th>
|
||||
<th>After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>{'{ "$path": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
|
||||
<tr><td><code>{'{ "$data": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
@@ -315,11 +331,19 @@ const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
|
||||
```
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `{ fn: "required" }` | `{ type: "required" }` |
|
||||
| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
|
||||
| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Before</th>
|
||||
<th>After</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><code>{'{ fn: "required" }'}</code></td><td><code>{'{ type: "required" }'}</code></td></tr>
|
||||
<tr><td><code>{'ValidationProvider functions={...}'}</code></td><td><code>{'ValidationProvider customFunctions={...}'}</code></td></tr>
|
||||
<tr><td><code>useFieldValidation(path, checks)</code></td><td><code>useFieldValidation(path, config)</code> where config is <code>{'{ checks, validateOn? }'}</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Visibility Provider
|
||||
|
||||
@@ -398,16 +422,57 @@ Action params in specs now use `statePath` instead of `path`.
|
||||
|
||||
The following exports have been removed from `@json-render/core`:
|
||||
|
||||
| Removed | Replacement |
|
||||
|---------|-------------|
|
||||
| `createCatalog` | `defineCatalog(schema, config)` |
|
||||
| `generateCatalogPrompt` | `catalog.prompt()` |
|
||||
| `generateSystemPrompt` | `catalog.prompt()` |
|
||||
| `ComponentDefinition` | Use catalog component config directly |
|
||||
| `CatalogConfig` | Use `defineCatalog` parameters |
|
||||
| `SystemPromptOptions` | Use `PromptOptions` |
|
||||
| `LogicExpression` | Use `VisibilityCondition` |
|
||||
| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
|
||||
| `evaluateLogicExpression` | Use `evaluateVisibility` |
|
||||
| `createRendererFromCatalog` | Use `defineRegistry` |
|
||||
| `traverseTree` (codegen) | Use `traverseSpec` |
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Removed</th>
|
||||
<th>Replacement</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>createCatalog</code></td>
|
||||
<td><code>defineCatalog(schema, config)</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>generateCatalogPrompt</code></td>
|
||||
<td><code>catalog.prompt()</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>generateSystemPrompt</code></td>
|
||||
<td><code>catalog.prompt()</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentDefinition</code></td>
|
||||
<td>Use catalog component config directly</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>CatalogConfig</code></td>
|
||||
<td>Use <code>defineCatalog</code> parameters</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>SystemPromptOptions</code></td>
|
||||
<td>Use <code>PromptOptions</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>LogicExpression</code></td>
|
||||
<td>Use <code>VisibilityCondition</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>AuthState</code></td>
|
||||
<td>Model auth as regular state (e.g. <code>/auth/isSignedIn</code>)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>evaluateLogicExpression</code></td>
|
||||
<td>Use <code>evaluateVisibility</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>createRendererFromCatalog</code></td>
|
||||
<td>Use <code>defineRegistry</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>traverseTree</code> (codegen)</td>
|
||||
<td>Use <code>traverseSpec</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -11,11 +11,18 @@ json-render includes common validation functions:
|
||||
|
||||
- `required` — Value must be non-empty
|
||||
- `email` — Valid email format
|
||||
- `minLength` — Minimum string length
|
||||
- `maxLength` — Maximum string length
|
||||
- `pattern` — Match a regex pattern
|
||||
- `min` — Minimum numeric value
|
||||
- `max` — Maximum numeric value
|
||||
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||
- `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
|
||||
|
||||
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
|
||||
|
||||
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
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
|
||||
- `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
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
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`
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Mechanism</th>
|
||||
<th>Trigger</th>
|
||||
<th>Use Case</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>on</code></td>
|
||||
<td>User interaction (press, change, blur)</td>
|
||||
<td>Button clicks, input changes, form submissions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>watch</code></td>
|
||||
<td>State value change (any source)</td>
|
||||
<td>Cascading data, derived state, cross-field sync</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
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
|
||||
@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "Schemas", href: "/docs/schemas" },
|
||||
{ title: "Catalog", href: "/docs/catalog" },
|
||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||
{ title: "Visibility", href: "/docs/visibility" },
|
||||
{ title: "Watchers", href: "/docs/watchers" },
|
||||
{ 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",
|
||||
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/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,9 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/streaming": "Streaming",
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/computed-values": "Computed Values",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/watchers": "Watchers",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
@@ -38,6 +40,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core API",
|
||||
"docs/api/react": "@json-render/react API",
|
||||
"docs/api/vue": "@json-render/vue API",
|
||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||
"docs/api/react-native": "@json-render/react-native API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -50,7 +50,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -33,7 +33,7 @@
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { streamText } from "ai";
|
||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||
import { imageCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const SYSTEM_PROMPT = imageCatalog.prompt();
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, startingSpec } = (await req.json()) as {
|
||||
prompt: string;
|
||||
startingSpec?: Spec | null;
|
||||
};
|
||||
|
||||
if (!prompt || typeof prompt !== "string") {
|
||||
return Response.json({ error: "prompt is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: startingSpec,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderToPng } from "@json-render/image/render";
|
||||
import { examples } from "@/lib/examples";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
let fontCache: ArrayBuffer | null = null;
|
||||
|
||||
async function loadFont(): Promise<ArrayBuffer> {
|
||||
if (fontCache) return fontCache;
|
||||
const fontPath = join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"geist",
|
||||
"dist",
|
||||
"fonts",
|
||||
"geist-sans",
|
||||
"Geist-Regular.ttf",
|
||||
);
|
||||
const buffer = await readFile(fontPath);
|
||||
fontCache = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
);
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const name = searchParams.get("name") ?? "og-image";
|
||||
const download = searchParams.get("download") === "1";
|
||||
|
||||
const example = examples.find((e) => e.name === name);
|
||||
if (!example) {
|
||||
return new Response("Example not found", { status: 404 });
|
||||
}
|
||||
|
||||
return imageResponse(example.spec, name, download);
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { spec, download, filename } = (await req.json()) as {
|
||||
spec: Spec;
|
||||
download?: boolean;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
if (!spec || !spec.root || !spec.elements) {
|
||||
return new Response("Invalid spec", { status: 400 });
|
||||
}
|
||||
|
||||
return imageResponse(spec, filename ?? "image", download ?? false);
|
||||
}
|
||||
|
||||
async function imageResponse(spec: Spec, name: string, download: boolean) {
|
||||
const fontData = await loadFont();
|
||||
const fonts = [
|
||||
{
|
||||
name: "Geist Sans",
|
||||
data: fontData,
|
||||
weight: 400 as const,
|
||||
style: "normal" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
|
||||
const disposition = download
|
||||
? `attachment; filename="${name}.png"`
|
||||
: `inline; filename="${name}.png"`;
|
||||
|
||||
return new Response(Buffer.from(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Disposition": disposition,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render Image Example",
|
||||
description: "Generate images from JSON specs with @json-render/image",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={geist.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { examples } from "@/lib/examples";
|
||||
import { createSpecStreamCompiler } from "@json-render/core";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { ImageIcon, Download, Loader2, ArrowRight, Square } from "lucide-react";
|
||||
|
||||
type Mode = "scratch" | "example";
|
||||
type MobileView = "json" | "preview";
|
||||
|
||||
interface Selection {
|
||||
mode: Mode;
|
||||
exampleName?: string;
|
||||
}
|
||||
|
||||
const IMAGE_REFRESH_INTERVAL_MS = 3000;
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
|
||||
>
|
||||
{copied ? "copied" : "copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function isRenderableSpec(spec: Spec | null): spec is Spec {
|
||||
if (!spec?.root || !spec.elements) return false;
|
||||
const root = spec.elements[spec.root];
|
||||
return root?.type === "Frame";
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selection, setSelection] = useState<Selection>({
|
||||
mode: "example",
|
||||
exampleName: examples[0]!.name,
|
||||
});
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||
const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const imageUrlRef = useRef<string | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const codeScrollRef = useRef<HTMLDivElement>(null);
|
||||
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generating) return;
|
||||
codeScrollRef.current?.scrollTo({
|
||||
top: codeScrollRef.current.scrollHeight,
|
||||
});
|
||||
mobileCodeScrollRef.current?.scrollTo({
|
||||
top: mobileCodeScrollRef.current.scrollHeight,
|
||||
});
|
||||
}, [generating, generatedSpec]);
|
||||
|
||||
const currentExample =
|
||||
selection.mode === "example"
|
||||
? examples.find((e) => e.name === selection.exampleName)
|
||||
: null;
|
||||
|
||||
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
|
||||
|
||||
const exampleImageUrl =
|
||||
selection.mode === "example" && !generatedSpec
|
||||
? `/api/image?name=${selection.exampleName}`
|
||||
: null;
|
||||
|
||||
const displayImageUrl = imageUrl ?? exampleImageUrl;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selection.mode, selection.exampleName]);
|
||||
|
||||
const fetchImageBlob = useCallback(
|
||||
async (spec: Spec, signal?: AbortSignal) => {
|
||||
const res = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spec }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to generate image");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const prev = imageUrlRef.current;
|
||||
imageUrlRef.current = url;
|
||||
setImageUrl(url);
|
||||
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const lastRefreshSpec = useRef<string>("");
|
||||
const generatedSpecRef = useRef<Spec | null>(null);
|
||||
generatedSpecRef.current = generatedSpec;
|
||||
|
||||
useEffect(() => {
|
||||
if (!generating) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const spec = generatedSpecRef.current;
|
||||
if (!spec) return;
|
||||
|
||||
const specKey = JSON.stringify(spec);
|
||||
if (specKey === lastRefreshSpec.current) return;
|
||||
if (!isRenderableSpec(spec)) return;
|
||||
|
||||
lastRefreshSpec.current = specKey;
|
||||
setRefreshing(true);
|
||||
fetchImageBlob(spec)
|
||||
.catch(() => {})
|
||||
.finally(() => setRefreshing(false));
|
||||
}, IMAGE_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [generating, fetchImageBlob]);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!prompt.trim()) return;
|
||||
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setGenerating(true);
|
||||
setError(null);
|
||||
lastRefreshSpec.current = "";
|
||||
|
||||
try {
|
||||
const startingSpec =
|
||||
selection.mode === "example" && currentExample
|
||||
? currentExample.spec
|
||||
: null;
|
||||
|
||||
const res = await fetch("/api/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Generation failed");
|
||||
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) throw new Error("No response body");
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const compiler = createSpecStreamCompiler<Spec>(
|
||||
startingSpec ? { ...startingSpec } : {},
|
||||
);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
if (newPatches.length > 0) setGeneratedSpec(result);
|
||||
}
|
||||
|
||||
const finalSpec = compiler.getResult();
|
||||
setGeneratedSpec(finalSpec);
|
||||
setGenerating(false);
|
||||
|
||||
await fetchImageBlob(finalSpec);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [prompt, selection, currentExample, fetchImageBlob]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setGenerating(false);
|
||||
|
||||
if (isRenderableSpec(generatedSpec)) {
|
||||
fetchImageBlob(generatedSpec).catch(() => {});
|
||||
}
|
||||
}, [generatedSpec, fetchImageBlob]);
|
||||
|
||||
const select = (next: Selection) => {
|
||||
abortRef.current?.abort();
|
||||
setSelection(next);
|
||||
setGeneratedSpec(null);
|
||||
setImageUrl(null);
|
||||
setError(null);
|
||||
setPrompt("");
|
||||
setGenerating(false);
|
||||
setExamplesSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!activeSpec) return;
|
||||
if (generatedSpec) {
|
||||
const res = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spec: generatedSpec, download: true }),
|
||||
});
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "image.png";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else if (selection.mode === "example") {
|
||||
window.open(
|
||||
`/api/image?name=${selection.exampleName}&download=1`,
|
||||
"_blank",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleGenerate();
|
||||
}
|
||||
},
|
||||
[handleGenerate],
|
||||
);
|
||||
|
||||
const jsonCode = activeSpec
|
||||
? JSON.stringify(activeSpec, null, 2)
|
||||
: "// select an example or generate an image";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: Chat / Examples
|
||||
// ---------------------------------------------------------------------------
|
||||
const chatPane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-2">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
json-render / image
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
start
|
||||
</p>
|
||||
<button
|
||||
onClick={() => select({ mode: "scratch" })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "scratch"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">From scratch</span>
|
||||
</button>
|
||||
|
||||
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
examples
|
||||
</p>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => select({ mode: "example", exampleName: ex.name })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "example" &&
|
||||
selection.exampleName === ex.name
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{ex.label}</span>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||
{ex.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div
|
||||
className="border-t border-border p-3 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selection.mode === "scratch"
|
||||
? "Describe the image you want..."
|
||||
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||
}
|
||||
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</span>
|
||||
{generating ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="h-3 w-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Generate"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: JSON Spec
|
||||
// ---------------------------------------------------------------------------
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-foreground">json</span>
|
||||
{generating && (
|
||||
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeSpec && <CopyButton text={jsonCode} />}
|
||||
</div>
|
||||
<div ref={codeScrollRef} className="flex-1 overflow-auto">
|
||||
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||
{jsonCode}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pane: Image Preview
|
||||
// ---------------------------------------------------------------------------
|
||||
const previewPane = (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
<span className="text-xs font-mono text-foreground">preview</span>
|
||||
{(generating || refreshing) && (
|
||||
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{activeSpec && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 relative bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||
{displayImageUrl ? (
|
||||
<img
|
||||
key={displayImageUrl}
|
||||
src={displayImageUrl}
|
||||
alt="Generated image"
|
||||
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<ImageIcon className="h-10 w-10" />
|
||||
<p className="text-sm">
|
||||
{selection.mode === "scratch"
|
||||
? "Enter a prompt to generate an image"
|
||||
: "Select an example to preview"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
return (
|
||||
<div className="h-dvh flex flex-col">
|
||||
{/* Desktop: 3-pane resizable layout */}
|
||||
<div className="hidden lg:flex flex-1 min-h-0">
|
||||
<ResizablePanelGroup className="flex-1">
|
||||
<ResizablePanel defaultSize={25} minSize={15}>
|
||||
{chatPane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={20}>
|
||||
{codePane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
{previewPane}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: toolbar + content + prompt */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
<div className="border-b border-border h-10 flex items-center px-1">
|
||||
<button
|
||||
onClick={() => setExamplesSheetOpen(true)}
|
||||
className="px-3 h-full text-xs font-mono text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setMobileView("json")}
|
||||
className={cn(
|
||||
"px-3 h-full text-xs font-mono transition-colors",
|
||||
mobileView === "json"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
json
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileView("preview")}
|
||||
className={cn(
|
||||
"px-3 h-full text-xs font-mono transition-colors",
|
||||
mobileView === "preview"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
preview
|
||||
</button>
|
||||
{activeSpec && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 h-full text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileView === "json" ? (
|
||||
<div ref={mobileCodeScrollRef} className="h-full overflow-auto">
|
||||
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||
{jsonCode}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||
{displayImageUrl ? (
|
||||
<img
|
||||
key={displayImageUrl}
|
||||
src={displayImageUrl}
|
||||
alt="Generated image"
|
||||
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<ImageIcon className="h-10 w-10" />
|
||||
<p className="text-sm">
|
||||
{selection.mode === "scratch"
|
||||
? "Enter a prompt to generate an image"
|
||||
: "Select an example"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border-t border-border p-3 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
mobileInputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={mobileInputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selection.mode === "scratch"
|
||||
? "Describe the image you want..."
|
||||
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||
}
|
||||
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
{generating ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<Square className="h-3 w-3" fill="currentColor" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Generate"
|
||||
>
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
|
||||
<SheetContent side="left" className="w-72 p-0">
|
||||
<SheetTitle className="sr-only">Examples</SheetTitle>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-2 space-y-1">
|
||||
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
start
|
||||
</p>
|
||||
<button
|
||||
onClick={() => select({ mode: "scratch" })}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "scratch"
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">From scratch</span>
|
||||
</button>
|
||||
|
||||
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||
examples
|
||||
</p>
|
||||
{examples.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() =>
|
||||
select({ mode: "example", exampleName: ex.name })
|
||||
}
|
||||
className={cn(
|
||||
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||
selection.mode === "example" &&
|
||||
selection.exampleName === ex.name
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{ex.label}</span>
|
||||
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||
{ex.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/image/server";
|
||||
import { standardComponentDefinitions } from "@json-render/image/catalog";
|
||||
|
||||
export const imageCatalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export interface Example {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
// ==========================================================================
|
||||
// OG Image
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "og-image",
|
||||
label: "OG Image",
|
||||
description: "Social sharing card for Open Graph (1200x630)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#0f172a",
|
||||
padding: 60,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["top-row", "content", "bottom-row"],
|
||||
},
|
||||
"top-row": {
|
||||
type: "Row",
|
||||
props: { gap: 12, alignItems: "center" },
|
||||
children: ["logo-dot", "brand"],
|
||||
},
|
||||
"logo-dot": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 16,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
brand: {
|
||||
type: "Text",
|
||||
props: { text: "acme.dev", fontSize: 24, color: "#94a3b8" },
|
||||
children: [],
|
||||
},
|
||||
content: {
|
||||
type: "Column",
|
||||
props: { gap: 16 },
|
||||
children: ["title", "subtitle"],
|
||||
},
|
||||
title: {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "Build faster with the modern developer platform",
|
||||
level: "h1",
|
||||
color: "#f8fafc",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Ship production-ready apps in minutes, not months.",
|
||||
fontSize: 24,
|
||||
color: "#94a3b8",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"bottom-row": {
|
||||
type: "Row",
|
||||
props: { gap: 8, alignItems: "center" },
|
||||
children: ["url"],
|
||||
},
|
||||
url: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "acme.dev/platform",
|
||||
fontSize: 18,
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Blog Post Card
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "blog-card",
|
||||
label: "Blog Post Card",
|
||||
description: "Social card for a blog post with author info",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 60,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["header", "body", "footer"],
|
||||
},
|
||||
header: {
|
||||
type: "Row",
|
||||
props: { gap: 10, alignItems: "center" },
|
||||
children: ["category-badge"],
|
||||
},
|
||||
"category-badge": {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#dbeafe",
|
||||
borderRadius: 20,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
children: ["category-text"],
|
||||
},
|
||||
"category-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Engineering",
|
||||
fontSize: 16,
|
||||
color: "#1d4ed8",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
body: {
|
||||
type: "Column",
|
||||
props: { gap: 20 },
|
||||
children: ["blog-title", "blog-excerpt"],
|
||||
},
|
||||
"blog-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "How We Reduced API Latency by 90% with Edge Computing",
|
||||
level: "h1",
|
||||
color: "#0f172a",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"blog-excerpt": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "A deep dive into our migration from centralized servers to edge functions, and the performance gains we achieved along the way.",
|
||||
fontSize: 22,
|
||||
color: "#64748b",
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
footer: {
|
||||
type: "Row",
|
||||
props: { gap: 16, alignItems: "center" },
|
||||
children: ["author-avatar", "author-info"],
|
||||
},
|
||||
"author-avatar": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
backgroundColor: "#e2e8f0",
|
||||
borderRadius: 24,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-info": {
|
||||
type: "Column",
|
||||
props: { gap: 2 },
|
||||
children: ["author-name", "author-date"],
|
||||
},
|
||||
"author-name": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Sarah Chen",
|
||||
fontSize: 18,
|
||||
color: "#0f172a",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-date": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "February 15, 2026",
|
||||
fontSize: 16,
|
||||
color: "#94a3b8",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Event Banner
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "event-banner",
|
||||
label: "Event Banner",
|
||||
description: "Wide promotional banner for an event (1920x1080)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
backgroundColor: "#18181b",
|
||||
padding: 100,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: [
|
||||
"badge-row",
|
||||
"spacer-1",
|
||||
"event-title",
|
||||
"spacer-2",
|
||||
"details-row",
|
||||
"spacer-3",
|
||||
"cta",
|
||||
],
|
||||
},
|
||||
"badge-row": {
|
||||
type: "Row",
|
||||
props: { alignItems: "center", justifyContent: "center" },
|
||||
children: ["live-badge"],
|
||||
},
|
||||
"live-badge": {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#dc2626",
|
||||
borderRadius: 24,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
},
|
||||
children: ["live-text"],
|
||||
},
|
||||
"live-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "LIVE EVENT",
|
||||
fontSize: 18,
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.1em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-1": {
|
||||
type: "Spacer",
|
||||
props: { height: 40 },
|
||||
children: [],
|
||||
},
|
||||
"event-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "DevConf 2026",
|
||||
level: "h1",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
letterSpacing: "-0.03em",
|
||||
lineHeight: 1.0,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-2": {
|
||||
type: "Spacer",
|
||||
props: { height: 32 },
|
||||
children: [],
|
||||
},
|
||||
"details-row": {
|
||||
type: "Row",
|
||||
props: {
|
||||
gap: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: ["date-text", "dot-sep", "location-text"],
|
||||
},
|
||||
"date-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "March 15-17, 2026",
|
||||
fontSize: 28,
|
||||
color: "#a1a1aa",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"dot-sep": {
|
||||
type: "Text",
|
||||
props: { text: "/", fontSize: 28, color: "#52525b" },
|
||||
children: [],
|
||||
},
|
||||
"location-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "San Francisco, CA",
|
||||
fontSize: 28,
|
||||
color: "#a1a1aa",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-3": {
|
||||
type: "Spacer",
|
||||
props: { height: 48 },
|
||||
children: [],
|
||||
},
|
||||
cta: {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
paddingLeft: 48,
|
||||
paddingRight: 48,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: ["cta-text"],
|
||||
},
|
||||
"cta-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Register Now",
|
||||
fontSize: 24,
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.02em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Social Square
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "social-square",
|
||||
label: "Social Square",
|
||||
description: "Square format card for Instagram or social media (1080x1080)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
backgroundColor: "#faf5ff",
|
||||
padding: 80,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["top-section", "quote-section", "bottom-section"],
|
||||
},
|
||||
"top-section": {
|
||||
type: "Row",
|
||||
props: {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["quote-icon", "slide-number"],
|
||||
},
|
||||
"quote-icon": {
|
||||
type: "Heading",
|
||||
props: { text: '"', level: "h1", color: "#9333ea" },
|
||||
children: [],
|
||||
},
|
||||
"slide-number": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "01 / 05",
|
||||
fontSize: 18,
|
||||
color: "#a855f7",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.1em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"quote-section": {
|
||||
type: "Column",
|
||||
props: { gap: 24 },
|
||||
children: ["quote-text"],
|
||||
},
|
||||
"quote-text": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "The best way to predict the future is to create it.",
|
||||
level: "h2",
|
||||
color: "#1e1b4b",
|
||||
letterSpacing: "-0.01em",
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"bottom-section": {
|
||||
type: "Column",
|
||||
props: { gap: 4 },
|
||||
children: ["author-name-sq", "author-title"],
|
||||
},
|
||||
"author-name-sq": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Peter Drucker",
|
||||
fontSize: 22,
|
||||
color: "#1e1b4b",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-title": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Management Consultant & Author",
|
||||
fontSize: 18,
|
||||
color: "#7c3aed",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Bar Graph
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "bar-graph",
|
||||
label: "Bar Graph",
|
||||
description: "Data visualization bar chart (1200x630)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 48,
|
||||
flexDirection: "column",
|
||||
},
|
||||
children: ["header", "chart-area"],
|
||||
},
|
||||
header: {
|
||||
type: "Column",
|
||||
props: { gap: 4 },
|
||||
children: ["chart-title", "chart-subtitle"],
|
||||
},
|
||||
"chart-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "Monthly Revenue",
|
||||
level: "h2",
|
||||
color: "#0f172a",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"chart-subtitle": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Q3-Q4 2025 (in thousands USD)",
|
||||
fontSize: 18,
|
||||
color: "#94a3b8",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"chart-area": {
|
||||
type: "Row",
|
||||
props: {
|
||||
gap: 0,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
},
|
||||
children: [
|
||||
"bar-jul",
|
||||
"bar-aug",
|
||||
"bar-sep",
|
||||
"bar-oct",
|
||||
"bar-nov",
|
||||
"bar-dec",
|
||||
],
|
||||
},
|
||||
|
||||
"bar-jul": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-jul", "rect-jul", "lbl-jul"],
|
||||
},
|
||||
"val-jul": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$42k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-jul": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 168,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-jul": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Jul",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-aug": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-aug", "rect-aug", "lbl-aug"],
|
||||
},
|
||||
"val-aug": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$58k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-aug": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 232,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-aug": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Aug",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-sep": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-sep", "rect-sep", "lbl-sep"],
|
||||
},
|
||||
"val-sep": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$51k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-sep": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 204,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-sep": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Sep",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-oct": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-oct", "rect-oct", "lbl-oct"],
|
||||
},
|
||||
"val-oct": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$73k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-oct": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 292,
|
||||
backgroundColor: "#818cf8",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-oct": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Oct",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-nov": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-nov", "rect-nov", "lbl-nov"],
|
||||
},
|
||||
"val-nov": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$85k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-nov": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 340,
|
||||
backgroundColor: "#818cf8",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-nov": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Nov",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-dec": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-dec", "rect-dec", "lbl-dec"],
|
||||
},
|
||||
"val-dec": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$97k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-dec": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 388,
|
||||
backgroundColor: "#4f46e5",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-dec": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Dec",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@resvg/resvg-js", "satori"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "example-image",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless image-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/image": "workspace:*",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"satori": "^0.19.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"ai": "6.0.103",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^0.575.0",
|
||||
"next": "16.1.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../packages/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +1,23 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
+59
-41
@@ -2,33 +2,31 @@
|
||||
|
||||
import { useState, useEffect, useCallback, type ReactNode } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
ValidationProvider,
|
||||
} from "@json-render/react";
|
||||
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||
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";
|
||||
|
||||
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
|
||||
return (
|
||||
<StateProvider initialState={spec.state ?? {}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<ValidationProvider>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ValidationProvider>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
<JSONUIProvider
|
||||
registry={registry}
|
||||
initialState={spec.state ?? {}}
|
||||
handlers={actionHandlers}
|
||||
functions={computedFunctions}
|
||||
>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const [selectedIndex] = useState(0);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const selected = examples[selectedIndex]!;
|
||||
const [confettiKey, setConfettiKey] = useState(0);
|
||||
const [confettiActive, setConfettiActive] = useState(false);
|
||||
@@ -41,30 +39,50 @@ export default function Page() {
|
||||
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center bg-muted/30">
|
||||
<div
|
||||
className="relative bg-background border rounded-lg shadow-sm"
|
||||
style={{ width: 960, height: 1080 }}
|
||||
>
|
||||
{confettiActive && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
<div className="h-screen flex flex-col bg-muted/30">
|
||||
{/* Example selector */}
|
||||
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
|
||||
{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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<ConfettiExplosion
|
||||
key={confettiKey}
|
||||
portal={false}
|
||||
force={0.8}
|
||||
duration={3500}
|
||||
particleCount={400}
|
||||
particleSize={8}
|
||||
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
|
||||
width={1600}
|
||||
height="200vh"
|
||||
zIndex={1}
|
||||
onComplete={() => setConfettiActive(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<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} />
|
||||
</div>
|
||||
)}
|
||||
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
|
||||
<SpecRenderer key={selectedIndex} spec={selected.spec} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
|
||||
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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import type { ComputedFunction } from "@json-render/core";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
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, {
|
||||
components: {
|
||||
...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?.(),
|
||||
};
|
||||
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"react-native": "0.81.4",
|
||||
"react-native-safe-area-context": "~5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.3",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"remotion": "4.0.418",
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
@@ -10,7 +10,7 @@
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# 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
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.2",
|
||||
"version": "0.0.4",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
@@ -10,7 +10,7 @@
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
),
|
||||
]),
|
||||
]),
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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$/ })],
|
||||
});
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
};
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
});
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "turbo run build",
|
||||
"dev": "turbo run dev --concurrency 15",
|
||||
"dev": "turbo run dev --concurrency 20",
|
||||
"lint": "turbo run lint",
|
||||
"format": "prettier --write \"**/*.{ts,tsx}\"",
|
||||
"type-check": "turbo run check-types",
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,78 @@
|
||||
# @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
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -188,6 +188,22 @@ Schema options:
|
||||
| `resolvePropValue(value, ctx)` | Resolve a single prop expression |
|
||||
| `resolveElementProps(props, ctx)` | Resolve all prop expressions in an element |
|
||||
| `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
|
||||
|
||||
@@ -285,6 +301,7 @@ The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@j
|
||||
| `Spec` | Base spec type |
|
||||
| `Catalog` | Catalog type |
|
||||
| `BuiltInAction` | Built-in action type (`name` + `description`) |
|
||||
| `ComputedFunction` | Function signature for `$computed` expressions |
|
||||
| `VisibilityCondition` | Visibility condition type (used by `$cond`) |
|
||||
| `VisibilityContext` | Context for evaluating visibility and prop expressions |
|
||||
| `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).
|
||||
|
||||
### 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
|
||||
|
||||
```typescript
|
||||
@@ -466,6 +521,65 @@ console.log(formatSpecIssues(issues));
|
||||
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
|
||||
|
||||
json-render supports completely different spec formats for different renderers:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.9.0",
|
||||
"version": "0.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
@@ -50,7 +50,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
|
||||
@@ -72,7 +72,11 @@ export {
|
||||
} from "./visibility";
|
||||
|
||||
// Prop Expressions
|
||||
export type { PropExpression, PropResolutionContext } from "./props";
|
||||
export type {
|
||||
PropExpression,
|
||||
PropResolutionContext,
|
||||
ComputedFunction,
|
||||
} from "./props";
|
||||
|
||||
export {
|
||||
resolvePropValue,
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
resolvePropValue,
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
_resetWarnedComputedFns,
|
||||
_resetWarnedTemplatePaths,
|
||||
} from "./props";
|
||||
import type { PropResolutionContext } from "./props";
|
||||
|
||||
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
|
||||
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
@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
|
||||
* repeat item — resolves via `repeatBasePath + path` and exposes the
|
||||
* absolute state path for write-back.
|
||||
* - `{ $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)
|
||||
*/
|
||||
export type PropExpression<T = unknown> =
|
||||
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
|
||||
$cond: VisibilityCondition;
|
||||
$then: 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.
|
||||
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
|
||||
export interface PropResolutionContext extends VisibilityContext {
|
||||
/** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
|
||||
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
|
||||
// =============================================================================
|
||||
@@ -193,6 +248,50 @@ export function resolvePropValue(
|
||||
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
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => resolvePropValue(item, ctx));
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
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
|
||||
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(" - url — valid URL format");
|
||||
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("Example:");
|
||||
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
|
||||
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
|
||||
lines.push("RULES:");
|
||||
const baseRules =
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,8 @@ export interface SpecIssue {
|
||||
| "orphaned_element"
|
||||
| "empty_spec"
|
||||
| "on_in_props"
|
||||
| "repeat_in_props";
|
||||
| "repeat_in_props"
|
||||
| "watch_in_props";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,6 +152,16 @@ export function validateSpec(
|
||||
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)
|
||||
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,12 @@ export interface UIElement<
|
||||
on?: Record<string, ActionBinding | ActionBinding[]>;
|
||||
/** Repeat children once per item in a state array */
|
||||
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[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
|
||||
).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", () => {
|
||||
@@ -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", () => {
|
||||
it("creates matches check with path reference", () => {
|
||||
const c = check.matches("/password", "Passwords must match");
|
||||
@@ -400,4 +636,109 @@ describe("check helper", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user