Compare commits

...
Author SHA1 Message Date
Chris Tate 553c803422 image (#173)
* image

* fixes

* fixes

* fix ci

* fix ci

* disable @next/next/no-img-element rule in ESLint configuration

* update route.ts to use path.join for font resolution and adjust next-env.d.ts import path
2026-02-27 13:03:42 -06:00
Chris Tate 9f58d8712c fix docs (#167)
* fix docs

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

* faster builds

* fixes

* Revert "fixes"

This reverts commit d0b43db972.

* Revert "faster builds"

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

* fixes

* fixes

* fixes

* fix ci

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

* feat: add a vite example

* use css instead of inline styles

* feat: add tests

* chore: use portless

* fix: update reactivity

* chore: update

* chore: build

* feat: add hooks, add missing zod peer deps
2026-02-25 08:21:11 -06:00
157 changed files with 13554 additions and 382 deletions
+3 -1
View File
@@ -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": [],
+1
View File
@@ -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
+50 -6
View File
@@ -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,7 +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-store` | XState Store (atom) adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
## Renderers
@@ -149,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
@@ -318,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
@@ -337,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
@@ -352,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
+9
View File
@@ -1,5 +1,14 @@
# 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
@@ -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&lt;string, unknown&gt;</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>
+69 -5
View File
@@ -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&lt;string, unknown&gt;</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&lt;string, ComputedFunction&gt;</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.
+225 -52
View File
@@ -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
+337
View File
@@ -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&lt;string, unknown&gt;</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&lt;StateModel&gt;</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&lt;T | undefined&gt;</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&lt;T | undefined&gt;, 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&lt;boolean&gt;</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&lt;FieldValidationState&gt;</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&lt;string[]&gt;</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&lt;boolean&gt;</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&lt;CoreVisibilityContext&gt;</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>
+113
View File
@@ -5,6 +5,119 @@ 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
+14 -9
View File
@@ -9,6 +9,18 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/react" />
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:
+96 -31
View File
@@ -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>
+21 -4
View File
@@ -136,10 +136,27 @@ An element can watch multiple state paths. Each path maps to one or more action
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
<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.
+11
View File
@@ -69,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,
},
],
},
{
@@ -97,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" },
],
},
+3
View File
@@ -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",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.2",
"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:*",
+9
View File
@@ -1,5 +1,14 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.2",
"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:*",
+9
View File
@@ -1,5 +1,14 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.2",
"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:*",
+34
View File
@@ -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();
}
+79
View File
@@ -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",
},
});
}
+124
View File
@@ -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;
}
}
+22
View File
@@ -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>
);
}
+627
View File
@@ -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>
);
}
+23
View File
@@ -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 };
+143
View File
@@ -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,
};
+12
View File
@@ -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",
},
},
];
+7
View File
@@ -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,
});
+771
View File
@@ -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: [],
},
},
},
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@resvg/resvg-js", "satori"],
};
export default nextConfig;
+44
View File
@@ -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"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -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"]
}
+9
View File
@@ -1,5 +1,14 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.2",
"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:*",
+8
View File
@@ -1,5 +1,13 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.2",
"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",
+8
View File
@@ -1,5 +1,13 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.2",
"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:*",
+8
View File
@@ -1,5 +1,13 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.2",
"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,13 @@
# 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
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.3",
"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,13 @@
# 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
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.3",
"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"
+10
View File
@@ -0,0 +1,10 @@
# vite-renderers
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/vue@0.10.0
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@json-render multi-renderer demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f3f4f6;
min-height: 100vh;
}
#renderer-root {
max-width: 640px;
margin: 0 auto;
padding: 24px;
}
</style>
</head>
<body>
<div id="renderer-root"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+27
View File
@@ -0,0 +1,27 @@
{
"name": "vite-renderers",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "portless vite-renderers.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/vue": "workspace:*",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"vue": "^3.5.29",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}
+30
View File
@@ -0,0 +1,30 @@
import "./shared/styles.css";
import { demoSpec } from "./spec";
type Renderer = "vue" | "react";
const container = document.getElementById("renderer-root") as HTMLElement;
let unmountCurrent: (() => void) | null = null;
async function switchTo(renderer: Renderer) {
unmountCurrent?.();
container.innerHTML = "";
if (renderer === "vue") {
const mod = await import("./vue/mount.ts");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
} else {
const mod = await import("./react/mount.tsx");
mod.mount(container, renderer, demoSpec);
unmountCurrent = mod.unmount;
}
}
// The RendererTabs component (rendered by JSON renderer) dispatches this event
document.addEventListener("switch-renderer", (e: Event) => {
switchTo((e as CustomEvent<string>).detail as Renderer);
});
// Default: Vue
switchTo("vue");
+51
View File
@@ -0,0 +1,51 @@
import { useMemo } from "react";
import type { Spec } from "@json-render/core";
import {
StateProvider,
ActionProvider,
VisibilityProvider,
ValidationProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/react";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const { registry } = defineRegistry(catalog, {
components,
actions: actionStubs,
});
function DemoRenderer({ spec }: { spec: Spec }) {
const { get, set } = useStateStore();
const handlers = useMemo(() => makeHandlers(get, set), [get, set]);
return (
<ActionProvider handlers={handlers}>
<VisibilityProvider>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
);
}
export default function App({
initialRenderer = "vue",
spec,
}: {
initialRenderer?: string;
spec: Spec;
}) {
return (
<div className={`renderer-${initialRenderer}`}>
<StateProvider
initialState={{ ...spec.state, renderer: initialRenderer }}
>
<DemoRenderer spec={spec} />
</StateProvider>
</div>
);
}
@@ -0,0 +1,5 @@
import { schema } from "@json-render/react/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
@@ -0,0 +1,15 @@
import { createRoot, type Root } from "react-dom/client";
import type { Spec } from "@json-render/core";
import App from "./App";
let root: Root | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
root = createRoot(container);
root.render(<App initialRenderer={renderer} spec={spec} />);
}
export function unmount() {
root?.unmount();
root = null;
}
@@ -0,0 +1,155 @@
import type { Components } from "@json-render/react";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => (
<div
className={[
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" ")}
style={{
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
}}
>
{children}
</div>
),
Card: ({ props, children }) => (
<div className="json-render-card">
{props.title && (
<div className="json-render-card-title-wrap">
<h2 className="json-render-card-title">{props.title}</h2>
</div>
)}
{props.subtitle && (
<p className="json-render-card-subtitle">{props.subtitle}</p>
)}
{children}
</div>
),
Text: ({ props }) => (
<span
className={[
"json-render-text",
props.size && props.size !== "md" && `json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" ")}
style={props.color ? { color: props.color } : undefined}
>
{String(props.content ?? "")}
</span>
),
Button: ({ props, emit }) => (
<button
disabled={props.disabled}
onClick={() => emit("press")}
className={[
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" ")}
>
{props.label}
</button>
),
Badge: ({ props }) => (
<span
className="json-render-badge"
style={
props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined
}
>
{props.label}
</span>
),
ListItem: ({ props, emit }) => (
<div
onClick={() => emit("press")}
className={[
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" ")}
>
<div
className={[
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" ")}
>
{props.completed ? "✓" : ""}
</div>
<span
className={[
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" ")}
>
{props.title}
</span>
</div>
),
RendererBadge: ({ props }) => (
<span className="json-render-renderer-badge">
<span className="json-render-renderer-dot" />
{props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React"}
</span>
),
RendererTabs: ({ props, emit }) => (
<div className="json-render-renderer-tabs-wrapper">
<span className="json-render-renderer-tabs-label">Render</span>
<div className="json-render-renderer-tabs">
<button
onClick={() => emit("pressVue")}
className={[
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
Vue
</button>
<button
onClick={() => emit("pressReact")}
className={[
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" ")}
>
React
</button>
</div>
</div>
),
};
@@ -0,0 +1,99 @@
import { z } from "zod";
/**
* Shared catalog definition — imported by both vue/catalog.ts and react/catalog.ts.
* Each renderer calls schema.createCatalog(catalogDef) with its own schema instance.
*/
export const catalogDef = {
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
RendererTabs: {
props: z.object({ renderer: z.string() }),
slots: [],
description:
"Segmented tab control for switching between Vue and React renderers",
},
RendererBadge: {
props: z.object({ renderer: z.string() }),
slots: [],
description: "Badge indicating which renderer is currently active",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: { params: z.object({}), description: "Reset the counter to 0" },
toggleItem: {
params: z.object({ index: z.number() }),
description: "Toggle the completed state of a todo item",
},
switchToVue: {
params: z.object({}),
description: "Switch to the Vue renderer",
},
switchToReact: {
params: z.object({}),
description: "Switch to the React renderer",
},
},
};
@@ -0,0 +1,50 @@
type Get = (path: string) => unknown;
type Set = (path: string, value: unknown) => void;
/** Stub actions for defineRegistry (no-ops; real logic is in makeHandlers) */
export const actionStubs = {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
switchToVue: async () => {},
switchToReact: async () => {},
};
/** Creates action handlers that close over the state store's get/set */
export function makeHandlers(get: Get, set: Set) {
return {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) todos[index] = { ...item, completed: !item.completed };
set("/todos", todos);
},
switchToVue: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "vue" }),
);
},
switchToReact: async () => {
document.dispatchEvent(
new CustomEvent("switch-renderer", { detail: "react" }),
);
},
};
}
@@ -0,0 +1,248 @@
/* ---- Stack ---------------------------------------------------------------- */
.json-render-stack {
display: flex;
flex-direction: column;
align-items: stretch;
}
.json-render-stack--horizontal {
flex-direction: row;
align-items: center;
}
.json-render-stack--align-start { align-items: flex-start; }
.json-render-stack--align-center { align-items: center; }
.json-render-stack--align-end { align-items: flex-end; }
/* ---- Card ----------------------------------------------------------------- */
.json-render-card {
background-color: white;
border-radius: 12px;
border: 1px solid #e5e7eb;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.json-render-card-title-wrap {
margin-bottom: 4px;
}
.json-render-card-title {
font-size: 16px;
font-weight: 600;
color: #111827;
margin: 0;
}
.json-render-card-subtitle {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px 0;
}
/* ---- Text ----------------------------------------------------------------- */
.json-render-text {
font-size: 14px;
font-weight: 400;
color: #111827;
}
.json-render-text--sm { font-size: 12px; }
.json-render-text--lg { font-size: 16px; }
.json-render-text--xl { font-size: 24px; }
.json-render-text--medium { font-weight: 500; }
.json-render-text--bold { font-weight: 700; }
/* ---- Button --------------------------------------------------------------- */
.json-render-button {
padding: 8px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-weight: 500;
font-size: 14px;
transition: background 0.15s;
background-color: #3b82f6;
color: white;
}
.json-render-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.json-render-button--primary {
background-color: #3b82f6;
color: white;
}
.json-render-button--secondary {
background-color: #f3f4f6;
color: #374151;
}
.json-render-button--danger {
background-color: #fee2e2;
color: #dc2626;
}
/* ---- Badge ---------------------------------------------------------------- */
.json-render-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
/* ---- ListItem ------------------------------------------------------------- */
.json-render-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
background-color: #f9fafb;
border: 1px solid #e5e7eb;
}
.json-render-list-item--done {
background-color: #f0fdf4;
border-color: #bbf7d0;
}
.json-render-list-item-check {
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #d1d5db;
background-color: transparent;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: white;
}
.json-render-list-item-check--done {
border-color: #16a34a;
background-color: #16a34a;
}
.json-render-list-item-text {
font-size: 14px;
color: #111827;
text-decoration: none;
}
.json-render-list-item-text--done {
color: #6b7280;
text-decoration: line-through;
}
/* ---- RendererBadge -------------------------------------------------------- */
.json-render-renderer-badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 10px 15px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
/* default colors; overridden by renderer parent class below */
background-color: #e0f2fe;
color: #0369a1;
border: 1px solid #bae6fd;
}
.json-render-renderer-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
/* background overridden by renderer parent class below */
background-color: #0369a1;
}
/* ---- RendererTabs --------------------------------------------------------- */
.json-render-renderer-tabs-wrapper {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.json-render-renderer-tabs-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.json-render-renderer-tabs {
display: inline-flex;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
}
.json-render-renderer-tab {
padding: 6px 16px;
border: none;
border-right: 0;
cursor: pointer;
font-size: 13px;
font-weight: 500;
background-color: white;
color: #374151;
transition: background 0.15s;
}
.json-render-renderer-tab:first-child {
border-right: 1px solid #e5e7eb;
}
/* ---- Renderer-specific overrides ----------------------------------------- */
.renderer-vue .json-render-renderer-badge {
color: #42b883;
background-color: #42b88318;
border-color: #42b88340;
}
.renderer-vue .json-render-renderer-dot {
background-color: #42b883;
}
.renderer-vue .json-render-renderer-tab--active {
background-color: #42b883;
color: white;
}
.renderer-react .json-render-renderer-badge {
color: #149eca;
background-color: #149eca18;
border-color: #149eca40;
}
.renderer-react .json-render-renderer-dot {
background-color: #149eca;
}
.renderer-react .json-render-renderer-tab--active {
background-color: #149eca;
color: white;
}
+126
View File
@@ -0,0 +1,126 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
renderer: "vue",
count: 0,
todos: [
{ id: 1, title: "Learn JSON Render", completed: true },
{
id: 2,
title: "Try @json-render/vue or @json-render/react",
completed: false,
},
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"demo-title",
"renderer-tabs",
"renderer-badge",
"counter-card",
"milestone-badge",
"todos-card",
],
},
"demo-title": {
type: "Text",
props: {
content: "@json-render multi-renderer demo",
size: "xl",
weight: "bold",
},
},
"renderer-badge": {
type: "RendererBadge",
props: { renderer: { $state: "/renderer" } },
},
"renderer-tabs": {
type: "RendererTabs",
props: { renderer: { $state: "/renderer" } },
on: {
pressVue: { action: "switchToVue" },
pressReact: { action: "switchToReact" },
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
},
};
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import { StateProvider } from "@json-render/vue";
import DemoRenderer from "./DemoRenderer.vue";
const props = defineProps<{
initialRenderer?: string; spec: Spec }>();
const initialState = {
...props.spec.state,
renderer: props.initialRenderer ?? "vue" };
</script>
<template>
<div :class="`renderer-${props.initialRenderer ?? 'vue'}`">
<StateProvider :initial-state="initialState">
<DemoRenderer :spec="props.spec" />
</StateProvider>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { Spec } from "@json-render/core";
import {
ActionProvider, ValidationProvider, VisibilityProvider,
Renderer, defineRegistry, useStateStore,
} from "@json-render/vue";
import { catalog } from "./catalog";
import { components } from "./registry";
import { actionStubs, makeHandlers } from "../shared/handlers";
const props = defineProps<{ spec: Spec }>();
const { get, set } = useStateStore();
const { registry } = defineRegistry(catalog, { components, actions: actionStubs });
const handlers = makeHandlers(get, set);
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="props.spec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
@@ -0,0 +1,5 @@
import { schema } from "@json-render/vue/schema";
import { catalogDef } from "../shared/catalog-def";
export const catalog = schema.createCatalog(catalogDef);
export type AppCatalog = typeof catalog;
+15
View File
@@ -0,0 +1,15 @@
import { createApp, type App } from "vue";
import type { Spec } from "@json-render/core";
import VueApp from "./App.vue";
let app: App | null = null;
export function mount(container: HTMLElement, renderer: string, spec: Spec) {
app = createApp(VueApp, { initialRenderer: renderer, spec });
app.mount(container);
}
export function unmount() {
app?.unmount();
app = null;
}
+166
View File
@@ -0,0 +1,166 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) =>
h(
"div",
{
class: [
"json-render-stack",
props.direction === "horizontal" && "json-render-stack--horizontal",
props.align && `json-render-stack--align-${props.align}`,
]
.filter(Boolean)
.join(" "),
style: {
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
},
},
children,
),
Card: ({ props, children }) =>
h("div", { class: "json-render-card" }, [
props.title &&
h("div", { class: "json-render-card-title-wrap" }, [
h("h2", { class: "json-render-card-title" }, props.title),
]),
props.subtitle &&
h("p", { class: "json-render-card-subtitle" }, props.subtitle),
children,
]),
Text: ({ props }) =>
h(
"span",
{
class: [
"json-render-text",
props.size &&
props.size !== "md" &&
`json-render-text--${props.size}`,
props.weight &&
props.weight !== "normal" &&
`json-render-text--${props.weight}`,
]
.filter(Boolean)
.join(" "),
style: props.color ? { color: props.color } : undefined,
},
String(props.content ?? ""),
),
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
class: [
"json-render-button",
props.variant && `json-render-button--${props.variant}`,
]
.filter(Boolean)
.join(" "),
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
class: "json-render-badge",
style: props.color
? {
backgroundColor: `${props.color}20`,
color: props.color,
borderColor: `${props.color}40`,
}
: undefined,
},
props.label,
),
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
class: [
"json-render-list-item",
props.completed && "json-render-list-item--done",
]
.filter(Boolean)
.join(" "),
},
[
h(
"div",
{
class: [
"json-render-list-item-check",
props.completed && "json-render-list-item-check--done",
]
.filter(Boolean)
.join(" "),
},
props.completed ? "✓" : "",
),
h(
"span",
{
class: [
"json-render-list-item-text",
props.completed && "json-render-list-item-text--done",
]
.filter(Boolean)
.join(" "),
},
props.title,
),
],
),
RendererBadge: ({ props }) =>
h("span", { class: "json-render-renderer-badge" }, [
h("span", { class: "json-render-renderer-dot" }),
props.renderer === "vue" ? "Rendered with Vue" : "Rendered with React",
]),
RendererTabs: ({ props, emit }) =>
h("div", { class: "json-render-renderer-tabs-wrapper" }, [
h("span", { class: "json-render-renderer-tabs-label" }, "Render"),
h("div", { class: "json-render-renderer-tabs" }, [
h(
"button",
{
onClick: () => emit("pressVue"),
class: [
"json-render-renderer-tab",
props.renderer === "vue" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"Vue",
),
h(
"button",
{
onClick: () => emit("pressReact"),
class: [
"json-render-renderer-tab",
props.renderer === "react" && "json-render-renderer-tab--active",
]
.filter(Boolean)
.join(" "),
},
"React",
),
]),
]),
};
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [vue(), react({ include: /\.tsx$/ })],
});
+9
View File
@@ -0,0 +1,9 @@
# example-vue
## 0.1.1
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/vue@0.10.0
+26
View File
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>json-render vue example</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f9fafb;
color: #111827;
min-height: 100vh;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
{
"name": "example-vue",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "portless vue.json-render vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/vue": "workspace:*",
"vue": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vue-tsc": "^3.2.5"
}
}
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import { StateProvider } from "@json-render/vue";
import { demoSpec } from "./lib/spec";
import DemoRenderer from "./DemoRenderer.vue";
const initialState = demoSpec.state ?? {};
</script>
<template>
<!-- StateProvider sets up the reactive state store for the whole tree -->
<StateProvider :initial-state="initialState">
<DemoRenderer />
</StateProvider>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import {
ActionProvider,
ValidationProvider,
VisibilityProvider,
Renderer,
defineRegistry,
useStateStore,
} from "@json-render/vue";
import { catalog } from "./lib/catalog";
import { components } from "./lib/registry";
import { demoSpec } from "./lib/spec";
// Access the state store provided by the parent StateProvider
const { get, set } = useStateStore();
// Build registry — include stub actions to satisfy catalog types.
// The actual logic runs in the handlers below, which have direct
// access to the state store.
const { registry } = defineRegistry(catalog, {
components,
actions: {
increment: async () => {},
decrement: async () => {},
reset: async () => {},
toggleItem: async () => {},
},
});
// Action handlers — close over the state store's get/set so they
// can read and write state directly without needing an external store.
const handlers = {
increment: async () => {
set("/count", Number(get("/count") || 0) + 1);
},
decrement: async () => {
set("/count", Math.max(0, Number(get("/count") || 0) - 1));
},
reset: async () => {
set("/count", 0);
},
toggleItem: async (params: Record<string, unknown>) => {
const index = params.index as number;
const todos = (
get("/todos") as Array<{
id: number;
title: string;
completed: boolean;
}>
).slice();
const item = todos[index];
if (item) {
todos[index] = { ...item, completed: !item.completed };
}
set("/todos", todos);
},
};
</script>
<template>
<ActionProvider :handlers="handlers">
<VisibilityProvider>
<ValidationProvider>
<Renderer :spec="demoSpec" :registry="registry" />
</ValidationProvider>
</VisibilityProvider>
</ActionProvider>
</template>
+92
View File
@@ -0,0 +1,92 @@
import { schema } from "@json-render/vue/schema";
import { z } from "zod";
export const catalog = schema.createCatalog({
components: {
Stack: {
props: z.object({
gap: z.number().optional(),
padding: z.number().optional(),
direction: z.enum(["vertical", "horizontal"]).optional(),
align: z.enum(["start", "center", "end"]).optional(),
}),
slots: ["default"],
description:
"Layout container that stacks children vertically or horizontally",
},
Card: {
props: z.object({
title: z.string().optional(),
subtitle: z.string().optional(),
}),
slots: ["default"],
description: "A card container with optional title and subtitle",
},
Text: {
props: z.object({
content: z.string(),
size: z.enum(["sm", "md", "lg", "xl"]).optional(),
weight: z.enum(["normal", "medium", "bold"]).optional(),
color: z.string().optional(),
}),
slots: [],
description: "Displays a text string",
},
Button: {
props: z.object({
label: z.string(),
variant: z.enum(["primary", "secondary", "danger"]).optional(),
disabled: z.boolean().optional(),
}),
slots: [],
description: "A clickable button that emits a 'press' event",
},
Badge: {
props: z.object({
label: z.string(),
color: z.string().optional(),
}),
slots: [],
description: "A small badge/tag label",
},
ListItem: {
props: z.object({
title: z.string(),
description: z.string().optional(),
completed: z.boolean().optional(),
}),
slots: [],
description: "A single item in a list",
},
Input: {
props: z.object({
value: z.string().optional(),
placeholder: z.string().optional(),
}),
slots: [],
description: "A text input field that supports two-way state binding",
},
},
actions: {
increment: {
params: z.object({}),
description: "Increment the counter by 1",
},
decrement: {
params: z.object({}),
description: "Decrement the counter by 1",
},
reset: {
params: z.object({}),
description: "Reset the counter to 0",
},
toggleItem: {
params: z.object({
index: z.number(),
}),
description: "Toggle the completed state of a todo item",
},
},
});
export type AppCatalog = typeof catalog;
+213
View File
@@ -0,0 +1,213 @@
import { h } from "vue";
import type { Components } from "@json-render/vue";
import { useBoundProp } from "@json-render/vue";
import type { AppCatalog } from "./catalog";
export const components: Components<AppCatalog> = {
Stack: ({ props, children }) => {
const isHorizontal = props.direction === "horizontal";
return h(
"div",
{
style: {
display: "flex",
flexDirection: isHorizontal ? "row" : "column",
gap: props.gap ? `${props.gap}px` : undefined,
padding: props.padding ? `${props.padding}px` : undefined,
alignItems: props.align ?? (isHorizontal ? "center" : "stretch"),
},
},
children,
);
},
Card: ({ props, children }) =>
h(
"div",
{
style: {
backgroundColor: "white",
borderRadius: "12px",
border: "1px solid #e5e7eb",
padding: "20px",
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
},
},
[
props.title &&
h("div", { style: { marginBottom: "4px" } }, [
h(
"h2",
{
style: {
fontSize: "16px",
fontWeight: "600",
color: "#111827",
margin: 0,
},
},
props.title,
),
]),
props.subtitle &&
h(
"p",
{
style: {
fontSize: "13px",
color: "#6b7280",
margin: "0 0 12px 0",
},
},
props.subtitle,
),
children,
],
),
Text: ({ props }) => {
const sizeMap: Record<string, string> = {
sm: "12px",
md: "14px",
lg: "16px",
xl: "24px",
};
const weightMap: Record<string, string> = {
normal: "400",
medium: "500",
bold: "700",
};
return h(
"span",
{
style: {
fontSize: sizeMap[props.size ?? "md"] ?? "14px",
fontWeight: weightMap[props.weight ?? "normal"] ?? "400",
color: props.color ?? "#111827",
},
},
String(props.content ?? ""),
);
},
Button: ({ props, emit }) =>
h(
"button",
{
disabled: props.disabled,
onClick: () => emit("press"),
style: {
padding: "8px 16px",
borderRadius: "8px",
border: "none",
cursor: props.disabled ? "not-allowed" : "pointer",
fontWeight: "500",
fontSize: "14px",
transition: "background 0.15s",
opacity: props.disabled ? "0.5" : "1",
backgroundColor:
props.variant === "danger"
? "#fee2e2"
: props.variant === "secondary"
? "#f3f4f6"
: "#3b82f6",
color:
props.variant === "danger"
? "#dc2626"
: props.variant === "secondary"
? "#374151"
: "white",
},
},
props.label,
),
Badge: ({ props }) =>
h(
"span",
{
style: {
display: "inline-block",
padding: "4px 12px",
borderRadius: "999px",
fontSize: "13px",
fontWeight: "500",
backgroundColor: props.color ? `${props.color}20` : "#e0f2fe",
color: props.color ?? "#0369a1",
border: `1px solid ${props.color ? `${props.color}40` : "#bae6fd"}`,
},
},
props.label,
),
Input: ({ props, bindings }) => {
const [value, setValue] = useBoundProp<string>(
props.value as string | undefined,
bindings?.value,
);
return h("input", {
value: value ?? "",
placeholder: props.placeholder as string | undefined,
onInput: (e: Event) => setValue((e.target as HTMLInputElement).value),
style: {
padding: "8px 12px",
borderRadius: "8px",
border: "1px solid #d1d5db",
fontSize: "14px",
outline: "none",
width: "100%",
boxSizing: "border-box",
},
});
},
ListItem: ({ props, emit }) =>
h(
"div",
{
onClick: () => emit("press"),
style: {
display: "flex",
alignItems: "center",
gap: "12px",
padding: "10px 12px",
borderRadius: "8px",
cursor: "pointer",
backgroundColor: props.completed ? "#f0fdf4" : "#f9fafb",
border: `1px solid ${props.completed ? "#bbf7d0" : "#e5e7eb"}`,
},
},
[
h(
"div",
{
style: {
width: "18px",
height: "18px",
borderRadius: "50%",
border: `2px solid ${props.completed ? "#16a34a" : "#d1d5db"}`,
backgroundColor: props.completed ? "#16a34a" : "transparent",
flexShrink: "0",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "11px",
color: "white",
},
},
props.completed ? "✓" : "",
),
h(
"span",
{
style: {
fontSize: "14px",
color: props.completed ? "#6b7280" : "#111827",
textDecoration: props.completed ? "line-through" : "none",
},
},
props.title,
),
],
),
};
+138
View File
@@ -0,0 +1,138 @@
import type { Spec } from "@json-render/core";
export const demoSpec: Spec = {
root: "root",
state: {
count: 0,
name: "",
todos: [
{ id: 1, title: "Learn Vue 3", completed: true },
{ id: 2, title: "Try @json-render/vue", completed: false },
{ id: 3, title: "Build something awesome", completed: false },
],
},
elements: {
root: {
type: "Stack",
props: { gap: 24, padding: 24, direction: "vertical" },
children: [
"header",
"counter-card",
"milestone-badge",
"todos-card",
"input-card",
],
},
header: {
type: "Text",
props: {
content: "@json-render/vue demo",
size: "xl",
weight: "bold",
},
},
// ---- Counter card ----
"counter-card": {
type: "Card",
props: {
title: "Counter",
subtitle: "Click the buttons to change the count",
},
children: ["counter-body"],
},
"counter-body": {
type: "Stack",
props: { gap: 12, direction: "horizontal", align: "center" },
children: [
"decrement-btn",
"counter-value",
"increment-btn",
"reset-btn",
],
},
"decrement-btn": {
type: "Button",
props: { label: "−", variant: "secondary" },
on: { press: { action: "decrement" } },
},
"counter-value": {
type: "Text",
props: {
content: { $state: "/count" },
size: "xl",
weight: "bold",
},
},
"increment-btn": {
type: "Button",
props: { label: "+", variant: "primary" },
on: { press: { action: "increment" } },
},
"reset-btn": {
type: "Button",
props: { label: "Reset", variant: "danger" },
on: { press: { action: "reset" } },
},
// ---- Milestone badge (visible only when count >= 10) ----
"milestone-badge": {
type: "Badge",
props: { label: "Milestone reached: 10!", color: "#10b981" },
visible: { $state: "/count", gte: 10 },
},
// ---- Todos card ----
"todos-card": {
type: "Card",
props: { title: "Todo List", subtitle: "Your tasks" },
children: ["todos-list"],
},
"todos-list": {
type: "Stack",
props: { gap: 8, direction: "vertical" },
repeat: { statePath: "/todos", key: "id" },
children: ["todo-item"],
},
"todo-item": {
type: "ListItem",
props: {
title: { $item: "title" },
completed: { $item: "completed" },
},
on: {
press: { action: "toggleItem", params: { index: { $index: true } } },
},
},
// ---- Bound Input card (useBoundProp demo) ----
"input-card": {
type: "Card",
props: {
title: "Bound Input",
subtitle: "Type to update state — the display text reacts in real time",
},
children: ["input-body"],
},
"input-body": {
type: "Stack",
props: { gap: 12, direction: "vertical" },
children: ["name-input", "name-display"],
},
"name-input": {
type: "Input",
props: {
value: { $bindState: "/name" },
placeholder: "Enter your name…",
},
},
"name-display": {
type: "Text",
props: {
content: { $state: "/name" },
size: "md",
color: "#6b7280",
},
},
},
};
+4
View File
@@ -0,0 +1,4 @@
import { createApp } from "vue";
import App from "./App.vue";
createApp(App).mount("#app");
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
});
+7
View File
@@ -1,5 +1,12 @@
# @json-render/codegen
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.9.1",
"version": "0.10.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+71
View File
@@ -1,5 +1,76 @@
# @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
+114
View File
@@ -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:
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.9.1",
"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:*",
+167
View File
@@ -0,0 +1,167 @@
# @json-render/image
Image renderer for `@json-render/core`. Generate SVG and PNG images from JSON specs using [Satori](https://github.com/vercel/satori).
## Install
```bash
npm install @json-render/core @json-render/image
```
For PNG output, also install the optional peer dependency:
```bash
npm install @resvg/resvg-js
```
## Quick Start
### Render a spec to SVG
```typescript
import { renderToSvg } from "@json-render/image/render";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading", "subtitle"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
subtitle: {
type: "Text",
props: { text: "Generated from JSON", fontSize: 24, color: "#a0a0b0" },
children: [],
},
},
};
const svg = await renderToSvg(spec, {
fonts: [
{
name: "Inter",
data: await fetch("https://example.com/Inter-Regular.ttf").then((r) =>
r.arrayBuffer()
),
weight: 400,
style: "normal",
},
],
});
```
### Render to PNG
```typescript
import { renderToPng } from "@json-render/image/render";
const png = await renderToPng(spec, {
fonts: [
{
name: "Inter",
data: await readFile("./Inter-Regular.ttf"),
weight: 400,
style: "normal",
},
],
});
// Write to file
await writeFile("output.png", png);
```
### With a custom catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema, renderToSvg } from "@json-render/image";
import { standardComponentDefinitions } from "@json-render/image/catalog";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Badge: {
props: z.object({
label: z.string(),
color: z.string().nullable(),
}),
slots: [],
description: "A colored badge label",
},
},
});
```
## Standard Components
### Root
| Component | Description |
|-----------|-------------|
| `Frame` | Root image container. Defines width, height, and background. Must be the root element. |
### Layout
| Component | Description |
|-----------|-------------|
| `Box` | Generic container with padding, margin, background, border, and flex alignment. |
| `Row` | Horizontal flex layout with gap, align, justify. |
| `Column` | Vertical flex layout with gap, align, justify. |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | h1-h4 heading text with color and alignment. |
| `Text` | Body text with fontSize, color, weight, style, and alignment. |
| `Image` | Image from a URL with width, height, and borderRadius. |
### Decorative
| Component | Description |
|-----------|-------------|
| `Divider` | Horizontal line separator. |
| `Spacer` | Empty vertical space. |
## Server-Side APIs
```typescript
import { renderToSvg, renderToPng } from "@json-render/image/render";
// Render to an SVG string
const svg = await renderToSvg(spec, { fonts });
// Render to a PNG buffer (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
```
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `fonts` | `SatoriOptions['fonts']` | `[]` | Font data for text rendering |
| `width` | `number` | Frame prop | Override image width |
| `height` | `number` | Frame prop | Override image height |
| `registry` | `Record<string, Component>` | `{}` | Custom component overrides |
| `includeStandard` | `boolean` | `true` | Include standard components |
| `state` | `Record<string, unknown>` | `{}` | Initial state values |
## Server-Safe Import
Import schema and catalog definitions without pulling in React or Satori:
```typescript
import { schema, standardComponentDefinitions } from "@json-render/image/server";
```
## License
Apache-2.0
+85
View File
@@ -0,0 +1,85 @@
{
"name": "@json-render/image",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
"json",
"image",
"svg",
"png",
"og",
"satori",
"ai",
"generative-ui",
"llm",
"renderer"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/image"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
},
"./render": {
"types": "./dist/render.d.ts",
"import": "./dist/render.mjs",
"require": "./dist/render.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"satori": "^0.19.2"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.5.1",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"@resvg/resvg-js": ">=2.0.0",
"react": "^18.0.0 || ^19.0.0",
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"@resvg/resvg-js": {
"optional": true
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import type {
Catalog,
InferCatalogComponents,
InferComponentProps,
StateModel,
} from "@json-render/core";
export type { StateModel };
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: React.ReactNode;
emit: (event: string) => void;
}
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => React.ReactNode;
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
+228
View File
@@ -0,0 +1,228 @@
import { z } from "zod";
/**
* Standard component definitions for image catalogs.
*
* These define the available image components with their Zod prop schemas.
* All components render to Satori-compatible JSX (HTML-like elements with
* inline CSS flexbox styles).
*/
export const standardComponentDefinitions = {
// ==========================================================================
// Root
// ==========================================================================
Frame: {
props: z.object({
width: z.number(),
height: z.number(),
backgroundColor: z.string().nullable(),
padding: z.number().nullable(),
display: z.enum(["flex", "none"]).nullable(),
flexDirection: z.enum(["row", "column"]).nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
}),
slots: ["default"],
description:
"Root image container. Defines the output image dimensions and background. Must be the root element.",
example: { width: 1200, height: 630, backgroundColor: "#ffffff" },
},
// ==========================================================================
// Layout Components
// ==========================================================================
Box: {
props: z.object({
padding: z.number().nullable(),
paddingTop: z.number().nullable(),
paddingBottom: z.number().nullable(),
paddingLeft: z.number().nullable(),
paddingRight: z.number().nullable(),
margin: z.number().nullable(),
backgroundColor: z.string().nullable(),
borderWidth: z.number().nullable(),
borderColor: z.string().nullable(),
borderRadius: z.number().nullable(),
flex: z.number().nullable(),
width: z.union([z.number(), z.string()]).nullable(),
height: z.union([z.number(), z.string()]).nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
flexDirection: z.enum(["row", "column"]).nullable(),
position: z.enum(["relative", "absolute"]).nullable(),
top: z.number().nullable(),
left: z.number().nullable(),
right: z.number().nullable(),
bottom: z.number().nullable(),
overflow: z.enum(["visible", "hidden"]).nullable(),
}),
slots: ["default"],
description:
"Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.",
example: {
padding: 20,
backgroundColor: "#f9f9f9",
borderRadius: 8,
alignItems: "center",
},
},
Row: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Horizontal flex layout. Use for placing elements side by side.",
example: { gap: 10, alignItems: "center" },
},
Column: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
}),
slots: ["default"],
description:
"Vertical flex layout. Use for stacking elements top to bottom.",
example: { gap: 8, padding: 10 },
},
// ==========================================================================
// Content Components
// ==========================================================================
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
letterSpacing: z.union([z.number(), z.string()]).nullable(),
lineHeight: z.number().nullable(),
}),
slots: [],
description:
"Heading text at various levels. h1 is largest, h4 is smallest.",
example: { text: "Hello World", level: "h1", color: "#000000" },
},
Text: {
props: z.object({
text: z.string(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
fontWeight: z.enum(["normal", "bold"]).nullable(),
fontStyle: z.enum(["normal", "italic"]).nullable(),
lineHeight: z.number().nullable(),
letterSpacing: z.union([z.number(), z.string()]).nullable(),
textDecoration: z.enum(["none", "underline", "line-through"]).nullable(),
}),
slots: [],
description:
"Body text with configurable size, color, weight, and alignment.",
example: { text: "Some content here.", fontSize: 16, color: "#333333" },
},
Image: {
props: z.object({
src: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
borderRadius: z.number().nullable(),
objectFit: z.enum(["contain", "cover", "fill", "none"]).nullable(),
}),
slots: [],
description:
"Image from a URL. Specify width and/or height to control size. For placeholder images use https://picsum.photos/{width}/{height}?random={n}.",
example: {
src: "https://picsum.photos/400/300?random=1",
width: 400,
height: 300,
},
},
// ==========================================================================
// Decorative Components
// ==========================================================================
Divider: {
props: z.object({
color: z.string().nullable(),
thickness: z.number().nullable(),
marginTop: z.number().nullable(),
marginBottom: z.number().nullable(),
}),
slots: [],
description: "Horizontal line separator between content sections.",
example: { color: "#e5e7eb", thickness: 1 },
},
Spacer: {
props: z.object({
height: z.number().nullable(),
}),
slots: [],
description: "Empty vertical space between elements.",
example: { height: 20 },
},
};
export type StandardComponentDefinitions = typeof standardComponentDefinitions;
export type StandardComponentProps<
K extends keyof StandardComponentDefinitions,
> = StandardComponentDefinitions[K]["props"] extends { _output: infer O }
? O
: z.output<StandardComponentDefinitions[K]["props"]>;
+1
View File
@@ -0,0 +1 @@
export { standardComponents } from "./standard";
+265
View File
@@ -0,0 +1,265 @@
import React from "react";
import type { ComponentRenderProps, ComponentRegistry } from "../types";
import type { StandardComponentProps } from "../catalog";
const headingSizes: Record<string, number> = {
h1: 48,
h2: 36,
h3: 28,
h4: 22,
};
const headingWeights: Record<string, number> = {
h1: 700,
h2: 700,
h3: 600,
h4: 600,
};
/**
* Satori crashes on explicit `undefined` style values (e.g. `padding: undefined`).
* Strip them so only defined properties are passed.
*/
function cleanStyle(raw: Record<string, unknown>): React.CSSProperties {
const out: Record<string, unknown> = {};
for (const k in raw) {
if (raw[k] !== undefined && raw[k] !== null) {
out[k] = raw[k];
}
}
return out as React.CSSProperties;
}
// =============================================================================
// Root
// =============================================================================
function FrameComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Frame">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: p.display ?? "flex",
flexDirection: p.flexDirection ?? "column",
width: p.width,
height: p.height,
backgroundColor: p.backgroundColor ?? "white",
padding: p.padding,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
})}
>
{children}
</div>
);
}
// =============================================================================
// Layout Components
// =============================================================================
function BoxComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Box">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: p.flexDirection ?? "column",
padding: p.padding,
paddingTop: p.paddingTop,
paddingBottom: p.paddingBottom,
paddingLeft: p.paddingLeft,
paddingRight: p.paddingRight,
margin: p.margin,
backgroundColor: p.backgroundColor,
borderWidth: p.borderWidth,
borderColor: p.borderColor,
borderRadius: p.borderRadius,
borderStyle: p.borderWidth ? "solid" : undefined,
flex: p.flex,
width: p.width,
height: p.height,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
position: p.position,
top: p.top,
left: p.left,
right: p.right,
bottom: p.bottom,
overflow: p.overflow,
})}
>
{children}
</div>
);
}
function RowComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Row">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: "row",
gap: p.gap,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
padding: p.padding,
flex: p.flex,
flexWrap: p.wrap ? "wrap" : undefined,
})}
>
{children}
</div>
);
}
function ColumnComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Column">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: "column",
gap: p.gap,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
padding: p.padding,
flex: p.flex,
})}
>
{children}
</div>
);
}
// =============================================================================
// Content Components
// =============================================================================
function HeadingComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
const p = element.props;
const level = p.level ?? "h2";
return (
<div
style={cleanStyle({
display: "flex",
fontSize: headingSizes[level] ?? 36,
fontWeight: headingWeights[level] ?? 700,
color: p.color ?? "black",
textAlign: p.align ?? "left",
letterSpacing: p.letterSpacing,
lineHeight: p.lineHeight ?? 1.2,
})}
>
{p.text}
</div>
);
}
function TextComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Text">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
fontSize: p.fontSize ?? 16,
color: p.color ?? "black",
textAlign: p.align ?? "left",
fontWeight: p.fontWeight === "bold" ? 700 : 400,
fontStyle: p.fontStyle ?? "normal",
lineHeight: p.lineHeight ?? 1.4,
letterSpacing: p.letterSpacing,
textDecoration: p.textDecoration,
})}
>
{p.text}
</div>
);
}
function ImageComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Image">>) {
const p = element.props;
return (
<img
src={p.src}
width={p.width ?? undefined}
height={p.height ?? undefined}
style={cleanStyle({
borderRadius: p.borderRadius,
objectFit: p.objectFit ?? "contain",
})}
/>
);
}
// =============================================================================
// Decorative Components
// =============================================================================
function DividerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Divider">>) {
const p = element.props;
return (
<div
style={{
display: "flex",
width: "100%",
borderBottom: `${p.thickness ?? 1}px solid ${p.color ?? "#e5e7eb"}`,
marginTop: p.marginTop ?? 8,
marginBottom: p.marginBottom ?? 8,
}}
/>
);
}
function SpacerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Spacer">>) {
const p = element.props;
return <div style={{ display: "flex", height: p.height ?? 20 }} />;
}
// =============================================================================
// Registry
// =============================================================================
export const standardComponents: ComponentRegistry = {
Frame: FrameComponent,
Box: BoxComponent,
Row: RowComponent,
Column: ColumnComponent,
Heading: HeadingComponent,
Text: TextComponent,
Image: ImageComponent,
Divider: DividerComponent,
Spacer: SpacerComponent,
};
+34
View File
@@ -0,0 +1,34 @@
// Schema
export { schema, type ImageSchema, type ImageSpec } from "./schema";
// Core types (re-exported for convenience)
export type { Spec } from "@json-render/core";
// Catalog-aware types
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
// Renderer types
export type {
ComponentRenderProps,
ComponentRenderer,
ComponentRegistry,
} from "./types";
// Standard components
export { standardComponents } from "./components";
// Server-side render functions
export { renderToSvg, renderToPng, type RenderOptions } from "./render";
// Catalog definitions
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
+115
View File
@@ -0,0 +1,115 @@
import { describe, it, expect } from "vitest";
import type { Spec } from "@json-render/core";
import { renderToSvg, renderToPng } from "./render";
const minimalSpec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: {
width: 400,
height: 200,
backgroundColor: "#ffffff",
},
children: ["box"],
},
box: {
type: "Box",
props: {
width: 100,
height: 50,
backgroundColor: "#ff0000",
borderRadius: 8,
},
children: [],
},
},
};
describe("renderToSvg", () => {
it("produces a valid SVG string", async () => {
const svg = await renderToSvg(minimalSpec);
expect(svg).toContain("<svg");
expect(svg).toContain("</svg>");
});
it("respects width/height from Frame props", async () => {
const svg = await renderToSvg(minimalSpec);
expect(svg).toContain('width="400"');
expect(svg).toContain('height="200"');
});
it("uses explicit width/height options over Frame props", async () => {
const svg = await renderToSvg(minimalSpec, { width: 800, height: 600 });
expect(svg).toContain('width="800"');
expect(svg).toContain('height="600"');
});
it("falls back to defaults when Frame has no dimensions", async () => {
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { backgroundColor: "#000" },
children: [],
},
},
};
const svg = await renderToSvg(spec);
expect(svg).toContain('width="1200"');
expect(svg).toContain('height="630"');
});
it("gracefully skips unknown component types", async () => {
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 400, height: 200 },
children: ["unknown"],
},
unknown: {
type: "NonExistent",
props: {},
children: [],
},
},
};
const svg = await renderToSvg(spec);
expect(svg).toContain("<svg");
});
it("renders without standard components when includeStandard is false", async () => {
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 400, height: 200 },
children: [],
},
},
};
const svg = await renderToSvg(spec, { includeStandard: false });
expect(svg).toContain("<svg");
});
});
describe("renderToPng", () => {
it("produces a buffer with content", async () => {
const png = await renderToPng(minimalSpec);
expect(png.length).toBeGreaterThan(0);
expect(png.byteLength).toBeGreaterThan(0);
});
it("starts with PNG magic bytes", async () => {
const png = await renderToPng(minimalSpec);
expect(png[0]).toBe(0x89);
expect(png[1]).toBe(0x50); // P
expect(png[2]).toBe(0x4e); // N
expect(png[3]).toBe(0x47); // G
});
});
+210
View File
@@ -0,0 +1,210 @@
import React from "react";
import satori, { type SatoriOptions } from "satori";
import type { Spec, UIElement } from "@json-render/core";
import {
resolveElementProps,
evaluateVisibility,
getByPath,
type PropResolutionContext,
} from "@json-render/core";
import { standardComponents } from "./components/standard";
import type { ComponentRegistry } from "./types";
export { standardComponents };
export interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean;
state?: Record<string, unknown>;
fonts?: SatoriOptions["fonts"];
/** Override the Frame width. When omitted, uses the Frame component's width prop. */
width?: number;
/** Override the Frame height. When omitted, uses the Frame component's height prop. */
height?: number;
}
const noopEmit = () => {};
function renderElement(
elementKey: string,
spec: Spec,
registry: ComponentRegistry,
stateModel: Record<string, unknown>,
repeatItem?: unknown,
repeatIndex?: number,
repeatBasePath?: string,
): React.ReactElement | null {
const element = spec.elements[elementKey];
if (!element) return null;
const ctx: PropResolutionContext = {
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
};
if (element.visible !== undefined) {
if (!evaluateVisibility(element.visible, ctx)) {
return null;
}
}
const resolvedProps = resolveElementProps(
element.props as Record<string, unknown>,
ctx,
);
const resolvedElement: UIElement = { ...element, props: resolvedProps };
const Component = registry[resolvedElement.type];
if (!Component) return null;
if (resolvedElement.repeat) {
const items =
(getByPath(stateModel, resolvedElement.repeat.statePath) as
| unknown[]
| undefined) ?? [];
const fragments = items.map((item, index) => {
const key =
resolvedElement.repeat!.key && typeof item === "object" && item !== null
? String(
(item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
index,
)
: String(index);
const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
item,
index,
childPath,
),
);
return (
<Component key={key} element={resolvedElement} emit={noopEmit}>
{children}
</Component>
);
});
return <>{fragments}</>;
}
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
),
);
return (
<Component key={elementKey} element={resolvedElement} emit={noopEmit}>
{children && children.length > 0 ? children : undefined}
</Component>
);
}
interface ImageDimensions {
width: number;
height: number;
}
function getDimensions(
spec: Spec,
options: RenderOptions = {},
): ImageDimensions {
if (options.width && options.height) {
return { width: options.width, height: options.height };
}
const rootElement = spec.elements[spec.root];
const props = rootElement?.props as Record<string, unknown> | undefined;
return {
width: options.width ?? (props?.width as number) ?? 1200,
height: options.height ?? (props?.height as number) ?? 630,
};
}
function buildTree(
spec: Spec,
options: RenderOptions = {},
): React.ReactElement {
const {
registry: customRegistry,
includeStandard = true,
state = {},
} = options;
const mergedState: Record<string, unknown> = {
...spec.state,
...state,
};
const registry: ComponentRegistry = {
...(includeStandard ? standardComponents : {}),
...customRegistry,
};
const root = renderElement(spec.root, spec, registry, mergedState);
return root ?? <></>;
}
/**
* Render a json-render spec to an SVG string.
*
* Uses Satori to convert the spec's component tree into SVG.
* No additional dependencies are needed beyond satori.
*/
export async function renderToSvg(
spec: Spec,
options: RenderOptions = {},
): Promise<string> {
const tree = buildTree(spec, options);
const { width, height } = getDimensions(spec, options);
return satori(tree as React.ReactNode, {
width,
height,
fonts: options.fonts ?? [],
});
}
/**
* Render a json-render spec to a PNG buffer.
*
* Requires `@resvg/resvg-js` to be installed as a peer dependency.
* The SVG is first generated via Satori, then rasterized to PNG.
*/
export async function renderToPng(
spec: Spec,
options: RenderOptions = {},
): Promise<Uint8Array> {
const svg = await renderToSvg(spec, options);
let Resvg: typeof import("@resvg/resvg-js").Resvg;
try {
const mod = await import("@resvg/resvg-js");
Resvg = mod.Resvg;
} catch {
throw new Error(
"@resvg/resvg-js is required for PNG output. Install it with: npm install @resvg/resvg-js",
);
}
const resvg = new Resvg(svg);
const pngData = resvg.render();
return pngData.asPng();
}
+54
View File
@@ -0,0 +1,54 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/image
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas
*
* Reuses the same { root, elements } spec format as the React and React PDF renderers.
*/
export const schema = defineSchema(
(s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
}),
}),
{
defaultRules: [
"The root element MUST be a Frame component. It defines the image dimensions (width, height) and background.",
"Frame width and height determine the output image size. Common sizes: 1200x630 (OG image), 1080x1080 (social square), 1920x1080 (banner).",
"Use Row for horizontal layouts and Column for vertical layouts. Both support gap, align, and justify props.",
"All text content must use Heading or Text components. Raw strings are not supported.",
"Image src must be a fully qualified URL. For placeholder images, use https://picsum.photos/{width}/{height}?random={n}.",
"Satori renders a subset of CSS: flexbox layout, borders, backgrounds, text styling. Absolute positioning is supported via position/top/left/right/bottom.",
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
],
},
);
export type ImageSchema = typeof schema;
export type ImageSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+20
View File
@@ -0,0 +1,20 @@
// Server-safe entry point: schema and catalog definitions only.
// Does not import React or Satori.
export { schema, type ImageSchema, type ImageSpec } from "./schema";
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
export type { Spec } from "@json-render/core";
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
+14
View File
@@ -0,0 +1,14 @@
import type { ComponentType, ReactNode } from "react";
import type { UIElement } from "@json-render/core";
export interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: ReactNode;
emit: (event: string) => void;
}
export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
ComponentRenderProps<P>
>;
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"@json-render/core",
"satori",
"@resvg/resvg-js",
"zod",
"react",
"react/jsx-runtime",
],
});
+7
View File
@@ -1,5 +1,12 @@
# @json-render/jotai
## 0.10.0
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
## 0.9.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.9.1",
"version": "0.10.0",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [

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