Compare commits

...
Author SHA1 Message Date
github-actions[bot] 023ca789b2 chore: version packages (#176)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-27 13:29:16 -06:00
Chris Tate 3f1e71e779 prepare v0.11.0 (#175)
* prepare v0.11.0

* release instructions
2026-02-27 13:25:55 -06:00
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
Chris Tate ea47b66dfc add dynamic forms support: computed values, watchers, cross-field validation (#156)
* add dynamic forms support: computed values, watchers, cross-field validation

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

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

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

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

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

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

* refactor: rename xstateStoreStateStore to xstateStore

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

* README updates

* Keep naming convention

---------

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

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

* docs: add v0.9.1 changelog entry
2026-02-24 06:25:34 -06:00
github-actions[bot] c502d5517e chore: version packages (#149)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-24 01:36:59 -06:00
Chris Tate 8740deb018 Update config.json (#148) 2026-02-24 01:34:16 -06:00
Chris Tate 1d755c104a prepare v0.9.0 (#147) 2026-02-24 01:21:02 -06:00
Chris Tate d904d45150 fix schema import to use server-safe subpath (#146)
- `@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`)
- Updated all docs, READMEs, examples, and skills to import `schema` from `@json-render/react/schema` instead of `@json-render/react`
- For combined imports, split into separate `schema` (subpath) and client API (main entry) lines

Fixes #123
2026-02-24 00:55:33 -06:00
Chris Tate a110c6e0ea fix chaining actions (#144)
* fix chaining actions

* improvements
2026-02-24 00:40:40 -06:00
Juzisuan965 7de08ccdf7 fix: safely resolve inner type for Zod arrays (#140) 2026-02-24 00:02:04 -06:00
Chris Tate 3201854481 external store adapter for state management (#139)
* external store adapter for state management

Introduces a `StateStore` interface that lets users plug in their own state management (Redux, Zustand, XState, etc.) instead of being locked into the internal `useState`-based store.

- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf

* improvements

* update docs

* improvements

* fixes

* fix CI

* add store adapters

* fixes

* fixes

* fixes

* fixes

* e2e tests

* improvements

* fixes

* fixes

* fixes

* fixes

* update lockfile for widened react peer deps

* fix dashboard build
2026-02-23 23:12:57 -06:00
Chris Tate 64c889221e fix playground og (#138) 2026-02-22 16:10:31 -06:00
Chris Tate 49838fa353 update og font (#137) 2026-02-22 16:02:07 -06:00
Chris Tate fa47b08869 update header font (#136) 2026-02-22 15:51:54 -06:00
Chris Tate 5ccb109c08 use visual-json (#135)
* use visual-json

* fix lint
2026-02-22 15:42:29 -06:00
Chris Tate 62932f6516 fix gitignore (#134) 2026-02-22 15:20:33 -06:00
Chris Tate ee28d548c1 use portless (#133) 2026-02-22 15:15:17 -06:00
Chris Tate 0e6f2afc6c fix og (#128) 2026-02-20 02:41:16 -06:00
Chris Tate bccedc2459 add docs (#127) 2026-02-20 02:30:33 -06:00
github-actions[bot] 0a404302ef chore: version packages (#126)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 02:17:13 -06:00
Chris Tate 09376db2f6 v0.8.0 changeset (#125) 2026-02-20 02:15:51 -06:00
Chris Tate 4c294417f4 pdf (#124)
* no-ai

* pdf

* pdf example

* fixes

* ai gateway

* fixes

* fixes

* fixes

* shadcn

* 3 panes

* fixes

* fixes

* fixes

* fixes

* fix CI

* fix
2026-02-20 02:08:25 -06:00
Brian Muenzenmeyer f77c1d6c98 Align rendering of ai-sdk table with other patterns (#122)
* Add table for AI SDK generation and chat modes

* Duplicate table for AI SDK generation modes
2026-02-18 21:02:38 -06:00
Chris Tate a66aef17f9 stripe updates (#121)
* stripe cleanup

* full screen flag

* fixes

* stripe cleanup

* refactor

* fixes

* progressive

* fix data

* fixes

* fixes actions

* fixes

* fixes
2026-02-18 13:12:21 -06:00
Chris Tate ba9ffa3c91 update star count (#120) 2026-02-18 09:21:04 -06:00
Chris Tate b7d5a75bfa update website docs (#119) 2026-02-17 02:26:57 -06:00
Chris Tate 0dfe07da45 v0.7.0 docs (#118) 2026-02-17 01:50:07 -06:00
github-actions[bot] c82eefd1c5 chore: version packages (#117)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-17 01:39:40 -06:00
Chris Tate 2d70fab00a v0.7.0 changeset (#116) 2026-02-17 01:37:35 -06:00
Chris Tate 320c935bfb shadcn (#115)
* shadcn

* fix build error

* fix readme

* fix CI error

* improvements

* fixes

* stronger types

* fix interleave

* fixes

* on arg

* minor fixes

* fix lock
2026-02-17 01:28:04 -06:00
github-actions[bot] e7103ce519 chore: version packages (#113)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-15 09:06:46 -06:00
Chris Tate 43ad534482 v0.6.1 changeset (#112) 2026-02-15 09:04:39 -06:00
417 changed files with 48262 additions and 1735 deletions
+10 -2
View File
@@ -6,9 +6,17 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/react-pdf",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
"@json-render/codegen"
"@json-render/codegen",
"@json-render/zustand",
"@json-render/redux",
"@json-render/jotai",
"@json-render/vue",
"@json-render/xstate",
"@json-render/image"
]
],
"linked": [],
@@ -16,7 +24,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"version": true,
"tag": false
}
}
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
+7 -6
View File
@@ -6,11 +6,8 @@ node_modules
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example
# Testing
coverage
@@ -43,4 +40,8 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
examples/stripe-app/*/.build
examples/stripe-app/*/yarn.lock
+77 -1
View File
@@ -25,12 +25,88 @@ This ensures we don't install outdated versions that may have incompatible types
## Code Style
- Do not use emojis in code or UI
- Do not use barrel files (index.ts that re-exports from other files)
- 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
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
```ts
import { streamText } from "ai";
const result = streamText({
model: "anthropic/claude-haiku-4.5",
prompt: "...",
});
```
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
## Dev Servers
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
Naming convention:
- Main web app: `json-render` → `json-render.localhost:1355`
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
```json
{
"scripts": {
"dev": "portless my-example-demo.json-render next dev --turbopack"
}
}
```
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
## Releases
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
### Fixed version group
All public `@json-render/*` packages are in a **fixed** group (see `.changeset/config.json`). A changeset that bumps any one of them bumps all of them to the same version. You only need to list the packages that actually changed in the changeset front matter — the fixed group handles the rest.
### Preparing a release
When asked to prepare a release (e.g. "prepare v0.12.0"):
1. **Create a changeset file** at `.changeset/v0-<N>-release.md` following the existing pattern:
- YAML front matter listing changed packages with bump type (`minor` for feature releases, `patch` for bug-fix-only releases)
- A one-line summary, then `### New:` / `### Improved:` / `### Fixed:` sections describing each change
- Always list `@json-render/core` plus any packages with actual code changes
2. **Do NOT bump versions** in `package.json` files — CI runs `pnpm ci:version` (which calls `changeset version`) to do that automatically
3. **Do NOT manually write `CHANGELOG.md`** entries — `changeset version` generates them from the changeset file
4. **Add new packages to the fixed group** in `.changeset/config.json` if they should be versioned together with the rest
5. **Fill documentation gaps** — every public package should have:
- A row in the root `README.md` packages table
- A renderer section in the root `README.md` (if it's a renderer)
- An API reference page at `apps/web/app/(main)/docs/api/<name>/page.mdx`
- An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts`
- An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`)
- A skill at `skills/json-render-<name>/SKILL.md`
- A `packages/<name>/README.md`
6. **Run `pnpm type-check`** after all changes to verify nothing is broken
### CI scripts
- `pnpm changeset` — interactively create a new changeset
- `pnpm ci:version` — run `changeset version` + lockfile update (CI only)
- `pnpm ci:publish` — build all packages and publish to npm (CI only)
<!-- opensrc:start -->
+161 -9
View File
@@ -5,11 +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
# or for mobile
# for React with pre-built shadcn/ui components
npm install @json-render/shadcn
# 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?
@@ -19,7 +26,8 @@ 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
@@ -27,7 +35,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -105,8 +113,16 @@ 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 |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
## Renderers
@@ -114,7 +130,7 @@ function Dashboard({ spec }) {
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
import { schema } from "@json-render/react/schema";
// Flat spec format (root key + elements map)
const spec = {
@@ -138,6 +154,59 @@ 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
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 36 standard definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
// Use matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
<Renderer spec={spec} registry={registry} />
```
### React Native (Mobile)
```tsx
@@ -185,6 +254,69 @@ const spec = {
/>
```
### React PDF (Documents)
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
```
### Image (SVG/PNG)
```typescript
import { renderToPng } from "@json-render/image/render";
const spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
// Render to PNG (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
// Or render to SVG string
import { renderToSvg } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
```
## Features
### Streaming (SpecStream)
@@ -240,10 +372,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
@@ -259,6 +393,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
@@ -270,10 +420,12 @@ pnpm install
pnpm dev
```
- http://localhost:3000 - Docs & Playground
- http://localhost:3001 - Example Dashboard
- http://localhost:3002 - Remotion Video Example
- http://json-render.localhost:1355 - Docs & Playground
- 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
+37
View File
@@ -0,0 +1,37 @@
# web
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/codegen@0.11.0
- @json-render/react@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+1 -1
View File
@@ -14,7 +14,7 @@ pnpm dev
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "A2UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/a2ui")
# A2UI Integration
@@ -66,7 +67,7 @@ A2UI uses an adjacency list model - a flat list of components with ID references
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// A2UI BoundValue schema
@@ -1,4 +1,5 @@
export const metadata = { title: "Adaptive Cards Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/adaptive-cards")
# Adaptive Cards Integration
@@ -89,7 +90,7 @@ Define a catalog matching the Adaptive Cards element types:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// Common Adaptive Cards properties
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "AG-UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ag-ui")
# AG-UI Integration
@@ -150,7 +151,7 @@ Create a catalog for UI components that agents can render:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const aguiCatalog = defineCatalog(schema, {
+40 -8
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "AI SDK Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ai-sdk")
# AI SDK Integration
@@ -191,13 +192,44 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
## Which Mode?
| | Generate | Chat |
|---|---|---|
| Output | JSONL only | Text + JSONL |
| Text-only replies | No | Yes |
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
| Use case | Playgrounds, builders | Chatbots, copilots |
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
Learn more in the [Generation Modes](/docs/generation-modes) guide.
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/codegen API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/codegen")
# @json-render/codegen
+39 -3
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/core API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/core")
# @json-render/core
@@ -10,7 +11,7 @@ Creates a type-safe catalog definition with schema validation.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
function defineCatalog<T extends ZodType>(
s: T,
@@ -118,7 +119,7 @@ The schema for flat UI element trees. This is exported from @json-render/react.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
@@ -141,6 +142,25 @@ const catalog = defineCatalog(schema, {
});
```
### SchemaOptions
When creating schemas with `defineSchema`, you can pass options:
```typescript
interface SchemaOptions {
promptTemplate?: PromptTemplate; // Custom AI prompt generator
defaultRules?: string[]; // Default rules injected before custom rules in prompts
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
}
interface BuiltInAction {
name: string; // Action name (e.g. "setState")
description: string; // Human-readable description for the LLM
}
```
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
### defineSchema
Create custom schemas for different output formats (e.g., page-based, block-based).
@@ -333,6 +353,21 @@ const flat = nestedToFlat({
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
```
### createJsonRenderTransform
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
```typescript
import { createJsonRenderTransform } from '@json-render/core';
const transform = createJsonRenderTransform();
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
```
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
### createMixedStreamParser
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
@@ -556,6 +591,7 @@ interface ActionBinding {
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
+364
View File
@@ -0,0 +1,364 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/image")
# @json-render/image
Image renderer. Turn JSON specs into SVG and PNG images 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
```
See the [Image example](https://github.com/vercel-labs/json-render/tree/main/examples/image) for a full working example.
## schema
The image element schema for image specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/image';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing image output. Both accept a spec and optional `RenderOptions`.
```typescript
import { renderToSvg, renderToPng } from '@json-render/image/render';
const svg = await renderToSvg(spec, { fonts });
const png = await renderToPng(spec, { fonts });
await writeFile('output.png', png);
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
fonts?: SatoriOptions['fonts'];
width?: number;
height?: number;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>fonts</code></td>
<td><code>{"SatoriOptions['fonts']"}</code></td>
<td><code>[]</code></td>
<td>Font data for text rendering (required for meaningful output)</td>
</tr>
<tr>
<td><code>width</code></td>
<td><code>number</code></td>
<td>Frame prop</td>
<td>Override the output image width</td>
</tr>
<tr>
<td><code>height</code></td>
<td><code>number</code></td>
<td>Frame prop</td>
<td>Override the output image height</td>
</tr>
<tr>
<td><code>registry</code></td>
<td><code>{"Record<string, ComponentRenderer>"}</code></td>
<td><code>{"{}"}</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td><code>boolean</code></td>
<td><code>true</code></td>
<td>Include built-in standard components</td>
</tr>
<tr>
<td><code>state</code></td>
<td><code>{"Record<string, unknown>"}</code></td>
<td><code>{"{}"}</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## Standard Components
### Root
#### Frame
Root image container. Defines the output image dimensions and background. Must be the root element.
```typescript
{
width: number;
height: number;
backgroundColor: string | null;
padding: number | null;
display: "flex" | "none" | null;
flexDirection: "row" | "column" | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
}
```
### Layout
#### Box
Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.
```typescript
{
padding: number | null;
paddingTop: number | null;
paddingBottom: number | null;
paddingLeft: number | null;
paddingRight: number | null;
margin: number | null;
backgroundColor: string | null;
borderWidth: number | null;
borderColor: string | null;
borderRadius: number | null;
flex: number | null;
width: number | string | null;
height: number | string | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
flexDirection: "row" | "column" | null;
position: "relative" | "absolute" | null;
top: number | null;
left: number | null;
right: number | null;
bottom: number | null;
overflow: "visible" | "hidden" | null;
}
```
#### Row
Horizontal flex layout with optional wrapping.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
wrap: boolean | null;
}
```
#### Column
Vertical flex layout.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
}
```
### Content
#### Heading
Heading text at various levels. h1 is largest, h4 is smallest.
```typescript
{
text: string;
level: "h1" | "h2" | "h3" | "h4" | null;
color: string | null;
align: "left" | "center" | "right" | null;
letterSpacing: number | string | null;
lineHeight: number | null;
}
```
#### Text
Body text with configurable size, color, weight, and alignment.
```typescript
{
text: string;
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
fontWeight: "normal" | "bold" | null;
fontStyle: "normal" | "italic" | null;
lineHeight: number | null;
letterSpacing: number | string | null;
textDecoration: "none" | "underline" | "line-through" | null;
}
```
#### Image
Image from a URL with optional dimensions and fit.
```typescript
{
src: string;
width: number | null;
height: number | null;
borderRadius: number | null;
objectFit: "contain" | "cover" | "fill" | "none" | null;
}
```
### Decorative
#### Divider
Horizontal line separator.
```typescript
{
color: string | null;
thickness: number | null;
marginTop: number | null;
marginBottom: number | null;
}
```
#### Spacer
Empty vertical space.
```typescript
{
height: number | null;
}
```
## Catalog Definitions
Pre-built definitions for creating image catalogs:
```typescript
import { standardComponentDefinitions } from '@json-render/image/catalog';
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/image';
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
// Add custom components
},
});
```
## Server-Safe Import
Import schema and catalog definitions without pulling in React or Satori:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/image/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/image</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/image/server</code></td>
<td>Schema and catalog definitions only (no React or Satori)</td>
</tr>
<tr>
<td><code>@json-render/image/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/image/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ImageSchema</code></td>
<td>Schema type for image specs</td>
</tr>
<tr>
<td><code>ImageSpec</code></td>
<td>Spec type for image output</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentRenderProps</code></td>
<td>Props passed to component render functions</td>
</tr>
<tr>
<td><code>ComponentRenderer</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>ComponentRegistry</code></td>
<td>Map of component names to render functions</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps{'<K>'}</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react-native API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-native")
# @json-render/react-native
@@ -8,65 +9,120 @@ 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
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={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:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-native";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — components re-render automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -164,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>
@@ -0,0 +1,439 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-pdf")
# @json-render/react-pdf
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
## Install
```bash
npm install @json-render/core @json-render/react-pdf
```
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
## schema
The PDF element schema for document specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
stream.pipe(res);
await renderToFile(spec, './output.pdf');
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>registry</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td>Include built-in standard components (default: <code>true</code>)</td>
</tr>
<tr>
<td><code>state</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
```tsx
import { defineRegistry } from '@json-render/react-pdf';
import { View, Text } from '@react-pdf/renderer';
const { registry } = defineRegistry(catalog, {
components: {
Badge: ({ props }) => (
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
<Text style={{ fontSize: 10 }}>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });
```
## createRenderer
Create a standalone renderer component wired to state, actions, and validation.
```typescript
import { createRenderer } from '@json-render/react-pdf';
const PDFRenderer = createRenderer(catalog, components);
```
```typescript
interface CreateRendererProps {
spec: Spec | null;
store?: StateStore;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
```
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
## Renderer
The main component that renders a spec to `@react-pdf/renderer` elements.
```typescript
interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
loading?: boolean;
fallback?: ComponentRenderer;
}
```
## Standard Components
### Document Structure
#### Document
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
```typescript
{
title: string | null;
author: string | null;
subject: string | null;
}
```
#### Page
A page in the document with configurable size, orientation, and margins.
```typescript
{
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
orientation: "portrait" | "landscape" | null;
marginTop: number | null;
marginBottom: number | null;
marginLeft: number | null;
marginRight: number | null;
backgroundColor: string | null;
}
```
### Layout
#### View
Generic container with padding, margin, background, border, and flex alignment.
```typescript
{
padding: number | null;
paddingTop: number | null;
paddingBottom: number | null;
paddingLeft: number | null;
paddingRight: number | null;
margin: number | null;
backgroundColor: string | null;
borderWidth: number | null;
borderColor: string | null;
borderRadius: number | null;
flex: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
}
```
#### Row
Horizontal flex layout with optional wrapping.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
wrap: boolean | null;
}
```
#### Column
Vertical flex layout.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
}
```
### Content
#### Heading
h1-h4 heading text with configurable color and alignment.
```typescript
{
text: string;
level: "h1" | "h2" | "h3" | "h4" | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
#### Text
Body text with full styling control.
```typescript
{
text: string;
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
fontWeight: "normal" | "bold" | null;
fontStyle: "normal" | "italic" | null;
lineHeight: number | null;
}
```
#### Image
Image from a URL with optional dimensions and fit.
```typescript
{
src: string;
width: number | null;
height: number | null;
objectFit: "contain" | "cover" | "fill" | "none" | null;
}
```
#### Link
Hyperlink with visible text.
```typescript
{
text: string;
href: string;
fontSize: number | null;
color: string | null;
}
```
### Data
#### Table
Data table with typed columns and string rows. Supports header styling and striped rows.
```typescript
{
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
rows: string[][];
headerBackgroundColor: string | null;
headerTextColor: string | null;
borderColor: string | null;
fontSize: number | null;
striped: boolean | null;
}
```
#### List
Ordered or unordered list.
```typescript
{
items: string[];
ordered: boolean | null;
fontSize: number | null;
color: string | null;
spacing: number | null;
}
```
### Decorative
#### Divider
Horizontal line separator.
```typescript
{
color: string | null;
thickness: number | null;
marginTop: number | null;
marginBottom: number | null;
}
```
#### Spacer
Empty vertical space.
```typescript
{
height: number | null;
}
```
### Page-Level
#### PageNumber
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
```typescript
{
format: string | null; // default: "{pageNumber} / {totalPages}"
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
## External Store (Controlled Mode)
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
```tsx
import { createStateStore, type StateStore } from "@json-render/react-pdf";
const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);
```
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
## Server-Safe Import
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/react-pdf</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/server</code></td>
<td>Schema and catalog definitions only (no React)</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ReactPdfSchema</code></td>
<td>Schema type for PDF specs</td>
</tr>
<tr>
<td><code>ReactPdfSpec</code></td>
<td>Spec type for PDF documents</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentContext</code></td>
<td>Typed component render function context</td>
</tr>
<tr>
<td><code>ComponentFn</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps&lt;K&gt;</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
+134 -4
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react")
# @json-render/react
@@ -9,11 +10,57 @@ React components, providers, and hooks.
### StateProvider
```tsx
<StateProvider initialState={object}>
<StateProvider initialState={object} onStateChange={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:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
@@ -46,7 +93,9 @@ type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<b
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, and `loading` with catalog-inferred types.
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.
```tsx
import { defineRegistry } from '@json-render/react';
@@ -81,16 +130,88 @@ 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
interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event
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 to check metadata like `shouldPreventDefault`:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
### BaseComponentProps
Catalog-agnostic base type for building reusable component libraries (e.g. `@json-render/shadcn`) that are not tied to a specific catalog:
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
## Hooks
@@ -171,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.
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/remotion API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/remotion")
# @json-render/remotion
@@ -0,0 +1,349 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/shadcn")
# @json-render/shadcn
Pre-built [shadcn/ui](https://ui.shadcn.com/) components for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
## Installation
```bash
npm install @json-render/shadcn @json-render/core @json-render/react zod
```
Your app must have Tailwind CSS configured.
## Entry Points
<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
Pick the components you need from the standard definitions:
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
// Catalog: pick definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
// Registry: pick matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
State actions (`setState`, `pushState`, `removeState`) are built into the React schema and handled by `ActionProvider` automatically. You don't need to declare them in your catalog.
## Extending with Custom Components
Add custom components alongside standard ones:
```typescript
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
// Standard
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Button: shadcnComponentDefinitions.Button,
// Custom
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
description: "KPI metric display",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Button: shadcnComponents.Button,
Metric: ({ props }) => (
<div>
<span>{props.label}</span>
<span>{props.value}</span>
</div>
),
},
});
```
## Available Components
### Layout
<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
<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
<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
<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
<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
<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
- The `/catalog` entry point has no React dependency -- use it for server-side prompt generation
- Components use Tailwind CSS classes -- your app must have Tailwind configured
- Component implementations use bundled shadcn/ui primitives (not your app's `components/ui/`)
- Form inputs support `checks` for validation (type + message pairs)
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
+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>
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Catalog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/catalog")
# Catalog
@@ -18,7 +19,7 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react'; // or '@json-render/react-native'
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
+332 -1
View File
@@ -1,9 +1,340 @@
export const metadata = { title: "Changelog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/changelog")
# Changelog
Notable changes and updates to json-render.
## v0.10.0
February 2026
### New: `@json-render/vue`
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
```bash
npm install @json-render/core @json-render/vue
```
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
```
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
See the [Vue API reference](/docs/api/vue) for details.
### New: `@json-render/xstate`
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
```bash
npm install @json-render/xstate @xstate/store
```
```typescript
import { createAtom } from "@xstate/store";
import { xstateStoreStateStore } from "@json-render/xstate";
const atom = createAtom({ count: 0 });
const store = xstateStoreStateStore({ atom });
```
Requires `@xstate/store` v3+.
### New: `$computed` and `$template` Expressions
Two new prop expression types for dynamic values:
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
### New: State Watchers
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
```json
{
"type": "Select",
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
"watch": {
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
}
}
```
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
### New: Cross-Field Validation
New built-in validation functions for cross-field comparisons:
- `equalTo` -- alias for `matches` with clearer semantics
- `lessThan` -- value must be less than another field
- `greaterThan` -- value must be greater than another field
- `requiredIf` -- required only when a condition field is truthy
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
### New: `validateForm` Action
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
```json
{
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
}
}
```
### Improved: shadcn/ui Validation
All form components now support `checks` and `validateOn` props:
- Checkbox, Radio, Switch added validation support
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
### New Examples
- **Vue example** -- standalone Vue 3 app with custom components
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
---
## v0.9.1
February 2026
### Fixed: Install failure due to private dependency
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
---
## v0.9.0
February 2026
### New: External State Store
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf
- Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
### Changed: `onStateChange` signature updated (breaking)
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
```ts
// Before
onStateChange?: (path: string, value: unknown) => void
// After
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
```
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
### Fixed: Server-safe schema import
`@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`.
For combined imports, split into separate `schema` (subpath) and client API (main entry) lines:
```ts
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
```
### Fixed: Chaining actions
Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`.
### Fixed: Zod array inner type resolution
Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`.
---
## v0.8.0
February 2026
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
```bash
npm install @json-render/core @json-render/react-pdf
```
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
```
Server-side rendering APIs:
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` -- render directly to a file
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
---
## v0.7.0
February 2026
### New: `@json-render/shadcn`
Pre-built [shadcn/ui](https://ui.shadcn.com/) component library for json-render. 36 components built on Radix UI + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
```bash
npm install @json-render/shadcn
```
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
Components include: layout (Card, Stack, Grid, Separator), navigation (Tabs, Accordion, Collapsible, Pagination), overlay (Dialog, Drawer, Tooltip, Popover, DropdownMenu), content (Heading, Text, Image, Avatar, Badge, Alert, Carousel, Table), feedback (Progress, Skeleton, Spinner), and input (Button, Link, Input, Textarea, Select, Checkbox, Radio, Switch, Slider, Toggle, ToggleGroup, ButtonGroup).
See the [API reference](/docs/api/shadcn) for full details.
### New: Event Handles (`on()`)
Components now receive an `on(event)` function in addition to `emit(event)`. The `on()` function returns an `EventHandle` with metadata:
- `emit()` -- fire the event
- `shouldPreventDefault` -- whether any action binding requested `preventDefault`
- `bound` -- whether any handler is bound to this event
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a href={props.href} onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}>{props.label}</a>
);
},
```
### New: `BaseComponentProps`
Catalog-agnostic base type for component render functions. Use when building reusable component libraries (like `@json-render/shadcn`) that are not tied to a specific catalog.
```typescript
import type { BaseComponentProps } from "@json-render/react";
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
<div>{props.title}{children}</div>
);
```
### New: Built-in Actions in Schema
Schemas can now declare `builtInActions` -- actions that are always available at runtime and automatically injected into prompts. The React schema declares `setState`, `pushState`, and `removeState` as built-in, so they appear in prompts without needing to be listed in catalog `actions`.
### New: `preventDefault` on `ActionBinding`
Action bindings now support a `preventDefault` boolean field, allowing the LLM to request that default browser behavior (e.g. navigation on links) be prevented.
### Improved: Stream Transform Text Block Splitting
`createJsonRenderTransform()` now properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs. This ensures the AI SDK creates separate text parts, preserving correct interleaving of prose and UI in `message.parts`.
### Improved: `defineRegistry` Actions Requirement
`defineRegistry` now conditionally requires the `actions` field only when the catalog declares actions. Catalogs with no actions (e.g. `actions: {}`) no longer need to pass an empty actions object.
---
## v0.6.0
February 2026
@@ -1,4 +1,5 @@
export const metadata = { title: "Code Export" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/code-export")
# Code Export
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
```bash
cd examples/dashboard
pnpm dev
# Open http://localhost:3001
# Open http://dashboard-demo.json-render.localhost:1355
# Generate a widget, then click "Export Project"
```
@@ -0,0 +1,119 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/computed-values")
# Computed Values
Derive dynamic prop values using registered functions or string templates.
## `$template` — String Interpolation
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
},
"children": []
}
```
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
Missing paths resolve to an empty string.
## `$computed` — Registered Functions
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
```json
{
"type": "Text",
"props": {
"text": {
"$computed": "fullName",
"args": {
"first": { "$state": "/form/firstName" },
"last": { "$state": "/form/lastName" }
}
}
},
"children": []
}
```
### Registering Functions
Functions are registered in the catalog and provided at runtime.
**Catalog definition (for AI prompt generation):**
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
fullName: {
description: 'Combines first and last name into a full name',
},
formatCurrency: {
description: 'Formats a number as currency',
},
},
});
```
**Runtime implementation:**
```tsx
import { JSONUIProvider } from '@json-render/react';
const functions = {
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
formatCurrency: (args) => {
const value = Number(args.value ?? 0);
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: (args.currency as string) ?? 'USD',
}).format(value);
},
};
<JSONUIProvider registry={registry} functions={functions}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### Using with `createRenderer`
```tsx
const MyRenderer = createRenderer(catalog, components);
<MyRenderer
spec={spec}
functions={functions}
/>
```
## Combining Expressions
`$computed` args can use any expression type. This example computes a total from repeat item fields:
```json
{
"$computed": "lineTotal",
"args": {
"price": { "$item": "price" },
"quantity": { "$item": "quantity" }
}
}
```
## Next
- [Watchers](/docs/watchers) — react to state changes with cascading actions
- [Data Binding](/docs/data-binding) — all expression types
- [Validation](/docs/validation) — validate form inputs
@@ -1,4 +1,5 @@
export const metadata = { title: "Custom Schema & Renderer" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/custom-schema")
# Custom Schema & Renderer
+47 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Data Binding" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/data-binding")
# Data Binding
@@ -212,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
The condition uses the same [visibility](/docs/visibility) expression format.
## Template Strings
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
```json
{
"type": "Text",
"props": {
"text": { "$template": "Welcome back, ${/user/name}!" }
},
"children": []
}
```
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
## Quick Reference
<div className="my-6 overflow-x-auto">
@@ -254,10 +271,39 @@ The condition uses the same [visibility](/docs/visibility) expression format.
<td><code>{'{ "$bindItem": "field" }'}</code></td>
<td>Form components inside repeat</td>
</tr>
<tr>
<td><code>{"$template"}</code></td>
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
<td>Anywhere (string props)</td>
</tr>
<tr>
<td><code>{"$computed"}</code></td>
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
<td>Anywhere (requires registered function)</td>
</tr>
</tbody>
</table>
</div>
## External Store (Controlled Mode)
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
```tsx
import { createStateStore, type StateStore } from "@json-render/react";
const store = createStateStore({ user: { name: "Alice" } });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — React re-renders automatically:
store.set("/user/name", "Bob");
```
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
## Next
- [Visibility](/docs/visibility) — conditionally show or hide elements
@@ -1,4 +1,5 @@
export const metadata = { title: "Generation Modes" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/generation-modes")
# Generation Modes
+32 -6
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Installation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/installation")
# Installation
@@ -8,6 +9,26 @@ 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:
<PackageInstall packages="@json-render/core @json-render/react @json-render/shadcn" />
Requires Tailwind CSS in your project. See the [@json-render/shadcn API reference](/docs/api/shadcn) for usage.
## For React Native
<PackageInstall packages="@json-render/core @json-render/react-native" />
@@ -16,14 +37,19 @@ Install the core package plus your renderer of choice.
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
## Peer Dependencies
## For External State Management (Optional)
json-render requires the following peer dependencies:
If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:
- `react` ^19.0.0
- `zod` ^4.0.0
<PackageInstall packages="@json-render/zustand" />
<PackageInstall packages="react zod" />
<PackageInstall packages="@json-render/redux" />
<PackageInstall packages="@json-render/jotai" />
<PackageInstall packages="@json-render/xstate" />
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
## For AI Integration
+98 -32
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Migration Guide" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/migration")
# Migration Guide
@@ -30,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
@@ -80,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
@@ -314,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
@@ -397,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>
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "OpenAPI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/openapi")
# OpenAPI Integration
@@ -99,7 +100,7 @@ Create components that map to OpenAPI data types:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const openapiCatalog = defineCatalog(schema, {
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Introduction" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs")
# Introduction
@@ -22,7 +23,7 @@ A catalog declares what AI can use: components with typed props, actions with ty
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
+45 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Quick Start" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/quick-start")
# Quick Start
@@ -11,7 +12,7 @@ Create a catalog that defines what components AI can use:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
@@ -163,9 +164,51 @@ export default function Page() {
}
```
## Quick Start with shadcn/ui
If you want to skip defining components from scratch, use `@json-render/shadcn` for 36 pre-built components:
```typescript
// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
export const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
```
```tsx
// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { shadcnComponents } from '@json-render/shadcn';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
## Next steps
- Learn about [catalogs](/docs/catalog) in depth
- Explore [data binding](/docs/data-binding) for dynamic values
- Add [action handlers](/docs/registry#action-handlers) for interactivity
- Implement [conditional visibility](/docs/visibility)
- Use [pre-built shadcn/ui components](/docs/api/shadcn) for fast prototyping
+30 -3
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Registry" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/registry")
# Registry
@@ -69,14 +70,40 @@ Each component receives a `ComponentContext` object:
interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
emit: (event: string) => void; // Emit a named event (e.g. "press")
emit: (event: string) => void; // Emit a named event (always defined)
on: (event: string) => EventHandle; // Get event handle with metadata
loading?: boolean; // Whether the renderer is in a loading state
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
}
```
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
Use `emit("press")` for simple event firing. Use `on("click")` when you need to inspect event metadata:
```tsx
Link: ({ props, on }) => {
const click = on("click");
return (
<a
href={props.href}
onClick={(e) => {
if (click.shouldPreventDefault) e.preventDefault();
click.emit();
}}
>
{props.label}
</a>
);
},
```
#### Using `bindings` for two-way binding
When a spec uses `{ "$bindState": "/path" }` or `{ "$bindItem": "field" }` on a prop, the renderer resolves the **value** into `props` and provides the **write-back path** in `bindings`. Use the `useBoundProp` hook to wire both together:
@@ -106,7 +133,7 @@ Actions are declared in your [catalog](/docs/catalog). The `@json-render/react`
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
+197
View File
@@ -0,0 +1,197 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/renderers")
# Renderers
json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format.
All renderers share the same workflow:
1. Define a catalog with `defineCatalog`
2. AI generates a JSON spec
3. The renderer turns the spec into platform-native output
<table>
<thead>
<tr>
<th>Renderer</th>
<th>Package</th>
<th>Output</th>
</tr>
</thead>
<tbody>
<tr>
<td>React</td>
<td><code>@json-render/react</code></td>
<td>React component tree</td>
</tr>
<tr>
<td>Vue</td>
<td><code>@json-render/vue</code></td>
<td>Vue 3 component tree</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td><code>@json-render/shadcn</code></td>
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
</tr>
<tr>
<td>React Native</td>
<td><code>@json-render/react-native</code></td>
<td>Native mobile views</td>
</tr>
<tr>
<td>Image</td>
<td><code>@json-render/image</code></td>
<td>SVG / PNG (via Satori)</td>
</tr>
<tr>
<td>React PDF</td>
<td><code>@json-render/react-pdf</code></td>
<td>PDF documents</td>
</tr>
<tr>
<td>Remotion</td>
<td><code>@json-render/remotion</code></td>
<td>Video compositions</td>
</tr>
</tbody>
</table>
## React
Render specs as React component trees in the browser. Supports data binding, streaming, actions, validation, visibility, and computed values.
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
## Vue
Vue 3 renderer with full feature parity with React: data binding, visibility, actions, validation, repeat scopes, and streaming.
```typescript
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]),
},
});
```
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
## shadcn/ui
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Button: shadcnComponentDefinitions.Button,
},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Button: shadcnComponents.Button,
},
});
```
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
## React Native
Render specs as native mobile views. Includes 25+ standard components and standard action definitions.
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />
```
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
## Image
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
```typescript
import { renderToSvg, renderToPng } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
const png = await renderToPng(spec, { fonts });
```
Nine standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. PNG output requires `@resvg/resvg-js` as an optional peer dependency.
See the [@json-render/image API reference](/docs/api/image) for details.
## React PDF
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
await renderToFile(spec, "./output.pdf");
```
Standard components include Document, Page, View, Row, Column, Heading, Text, Image, Table, List, Divider, Spacer, Link, and PageNumber.
See the [@json-render/react-pdf API reference](/docs/api/react-pdf) for details.
## Remotion
Turn JSON timeline specs into video compositions with Remotion.
```tsx
import { Player } from "@remotion/player";
import { Renderer } from "@json-render/remotion";
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
```
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
## Custom Renderers
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Schemas" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/schemas")
# Schemas
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Specs" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/specs")
# Specs
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Streaming" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/streaming")
# Streaming
+117 -9
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Validation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/validation")
# Validation
@@ -10,11 +11,18 @@ json-render includes common validation functions:
- `required` — Value must be non-empty
- `email` — Valid email format
- `minLength` — Minimum string length
- `maxLength` — Maximum string length
- `pattern` — Match a regex pattern
- `min` — Minimum numeric value
- `max` — Maximum numeric value
- `minLength` — Minimum string length (args: `{ "min": N }`)
- `maxLength` — Maximum string length (args: `{ "max": N }`)
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
- `min` — Minimum numeric value (args: `{ "min": N }`)
- `max` — Maximum numeric value (args: `{ "max": N }`)
- `numeric` — Value must be a number
- `url` — Valid URL format
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
## Using Validation in JSON
@@ -66,7 +74,7 @@ Define custom validators in your catalog's `functions` field. The catalog itself
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react'; // or '@json-render/react-native'
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
import { z } from 'zod';
const catalog = defineCatalog(schema, {
@@ -142,14 +150,114 @@ function TextField({ props, bindings }) {
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
## Cross-Field Validation
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
```json
{
"type": "Input",
"props": {
"label": "Confirm Password",
"value": { "$bindState": "/form/confirmPassword" },
"checks": [
{ "type": "required", "message": "Please confirm your password" },
{
"type": "matches",
"args": { "other": { "$state": "/form/password" } },
"message": "Passwords must match"
}
]
}
}
```
Other cross-field examples:
```json
{
"checks": [
{
"type": "greaterThan",
"args": { "other": { "$state": "/form/startDate" } },
"message": "End date must be after start date"
}
]
}
```
```json
{
"checks": [
{
"type": "requiredIf",
"args": { "field": { "$state": "/form/enableNotifications" } },
"message": "Email is required when notifications are enabled"
}
]
}
```
## Conditional Validation
Use the `enabled` field in the validation config to only run checks when a condition is met:
```json
{
"type": "Input",
"props": {
"label": "Company Name",
"value": { "$bindState": "/form/company" },
"checks": [
{ "type": "required", "message": "Company name is required" }
]
}
}
```
In the component implementation, you can pass `enabled` to `useFieldValidation`:
```typescript
useFieldValidation(bindings?.value ?? "", {
checks: props.checks ?? [],
enabled: { "$state": "/form/accountType", eq: "business" },
});
```
This only validates the company name when the account type is "business".
## Validation Timing
Control when validation runs with `validateOn`:
- `change` — Validate on every input change
- `blur` — Validate when field loses focus
- `blur` — Validate when field loses focus (default for Input, Textarea)
- `submit` — Validate only on form submission
## Form-Level Validation
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
```json
{
"type": "Button",
"props": { "label": "Submit" },
"on": {
"press": [
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
{ "action": "submitForm" }
]
},
"children": []
}
```
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
## Next
Learn about [generation modes](/docs/generation-modes).
- [Computed Values](/docs/computed-values) — derive dynamic prop values
- [Watchers](/docs/watchers) — react to state changes
- [Generation Modes](/docs/generation-modes) — how AI generates specs
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Visibility" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/visibility")
# Visibility
+167
View File
@@ -0,0 +1,167 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/watchers")
# Watchers
React to state changes by triggering actions when watched paths update.
## The `watch` Field
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
```json
{
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
},
"children": []
}
```
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
## Cascading Selects
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
```json
{
"root": "form",
"elements": {
"form": {
"type": "Stack",
"props": { "direction": "vertical", "gap": "md" },
"children": ["country-select", "city-select"]
},
"country-select": {
"type": "Select",
"props": {
"label": "Country",
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": [
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
]
},
"children": []
},
"city-select": {
"type": "Select",
"props": {
"label": "City",
"value": { "$bindState": "/form/city" },
"options": { "$state": "/availableCities" },
"placeholder": "Select a city"
},
"children": []
}
},
"state": {
"form": { "country": "", "city": "" },
"availableCities": []
}
}
```
The watcher on `country-select` fires two actions when the country changes:
1. `loadCities` — fetches and writes city options to `/availableCities`
2. `setState` — resets the city selection
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
### Action Handler
```typescript
const handlers = {
loadCities: async (params) => {
const cities = await fetchCities(params.country);
// setState is called by the runtime to write the result
return cities;
},
};
```
Or with `defineRegistry`:
```typescript
const { registry, handlers } = defineRegistry(catalog, {
components: { /* ... */ },
actions: {
loadCities: async (params, setState) => {
const response = await fetch(`/api/cities?country=${params.country}`);
const cities = await response.json();
setState('/availableCities', cities);
},
},
});
```
## Multiple Watchers
An element can watch multiple state paths. Each path maps to one or more action bindings:
```json
{
"watch": {
"/form/startDate": { "action": "validateDateRange" },
"/form/endDate": { "action": "validateDateRange" },
"/form/quantity": [
{ "action": "recalculateTotal" },
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
]
}
}
```
## Behavior
- Watchers only fire on **value changes**, not on the initial render
- Comparison is by reference (`===`), not deep equality
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
- Multiple action bindings on the same path execute sequentially
## When to Use `watch` vs `on`
<table>
<thead>
<tr>
<th>Mechanism</th>
<th>Trigger</th>
<th>Use Case</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>on</code></td>
<td>User interaction (press, change, blur)</td>
<td>Button clicks, input changes, form submissions</td>
</tr>
<tr>
<td><code>watch</code></td>
<td>State value change (any source)</td>
<td>Cascading data, derived state, cross-field sync</td>
</tr>
</tbody>
</table>
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
## Next
- [Data Binding](/docs/data-binding) — connect elements to state
- [Computed Values](/docs/computed-values) — derive prop values
- [Visibility](/docs/visibility) — conditionally show or hide elements
+1 -1
View File
@@ -16,7 +16,7 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
npm packages: @json-render/core, @json-render/react, @json-render/image, @json-render/remotion, @json-render/codegen
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+15 -4
View File
@@ -154,23 +154,34 @@ button {
margin-bottom: 0.5em;
}
/* MDX table styles — fallback for GFM-generated tables */
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
.mdx-table th,
.mdx-table td {
.mdx-table td,
article table th,
article table td {
border: 1px solid var(--border);
padding: 0.75rem 1rem;
text-align: left;
}
.mdx-table th {
.mdx-table th,
article table th {
font-weight: 600;
background-color: var(--muted);
}
.mdx-table td {
.mdx-table td,
article table td {
color: var(--muted-foreground);
}
article table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
margin: 1.5rem 0;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
robots: {
index: true,
@@ -92,7 +92,9 @@ export default async function RootLayout({
/>
)}
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
>
<ThemeProvider>
{children}
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
+15 -8
View File
@@ -5,19 +5,20 @@ import { join } from "node:path";
export { getPageTitle } from "@/lib/page-titles";
// Cache font data in memory after first load
let fontCache: { geistRegular: Buffer } | null = null;
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
async function loadFonts() {
if (fontCache) return fontCache;
const geistRegular = await readFile(
join(process.cwd(), "public/Geist-Regular.ttf"),
);
fontCache = { geistRegular };
const [geistRegular, geistPixelSquare] = await Promise.all([
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
]);
fontCache = { geistRegular, geistPixelSquare };
return fontCache;
}
export async function renderOgImage(title: string) {
const { geistRegular } = await loadFonts();
const { geistRegular, geistPixelSquare } = await loadFonts();
return new ImageResponse(
<div
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
<span
style={{
fontSize: 36,
fontFamily: "Geist",
fontWeight: 400,
fontFamily: "Geist Pixel Square",
fontWeight: 500,
color: "white",
}}
>
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
style: "normal",
weight: 400,
},
{
name: "Geist Pixel Square",
data: geistPixelSquare.buffer as ArrayBuffer,
style: "normal",
weight: 500,
},
],
},
);
+2 -5
View File
@@ -1,10 +1,7 @@
import { Playground } from "@/components/playground";
import { pageMetadata } from "@/lib/page-metadata";
import { PAGE_TITLES } from "@/lib/page-titles";
export const metadata = {
title: PAGE_TITLES["playground"],
};
export const metadata = pageMetadata("playground");
export default function PlaygroundPage() {
return <Playground />;
+2 -2
View File
@@ -57,7 +57,7 @@ export function Header() {
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
json-render
</span>
</Link>
@@ -100,7 +100,7 @@ export function Header() {
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
<span>10.5k</span>
<span>11k</span>
</a>
<ThemeToggle />
</nav>
+122 -31
View File
@@ -16,17 +16,20 @@ import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
import { JsonEditor } from "@visual-json/react";
import type { JsonValue } from "@visual-json/react";
import { PlaygroundRenderer } from "@/lib/render/renderer";
import { playgroundCatalog } from "@/lib/render/catalog";
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
type Tab = "json" | "nested" | "stream" | "catalog";
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
type RenderView = "preview" | "code";
type MobileView =
| "json"
| "nested"
| "stream"
| "catalog"
| "visual"
| "preview"
| "generated-code";
@@ -233,6 +236,20 @@ export function Playground() {
[handleSubmit],
);
const handleVisualChange = useCallback(
(value: JsonValue) => {
if (!selectedVersionId || isStreaming) return;
setVersions((prev) =>
prev.map((v) =>
v.id === selectedVersionId
? { ...v, tree: value as unknown as Spec }
: v,
),
);
},
[selectedVersionId, isStreaming],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
@@ -479,31 +496,69 @@ ${jsx}
? jsonCode
: activeTab === "nested"
? nestedCode
: "";
: activeTab === "visual"
? jsonCode
: "";
const codePane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`text-xs font-mono transition-colors ${
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{activeTab !== "catalog" && (
{activeTab !== "catalog" && activeTab !== "visual" && (
<CopyButton text={copyText} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{activeTab === "catalog" ? (
{activeTab === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : activeTab === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
@@ -735,19 +790,21 @@ ${jsx}
: 0}
</button>
{/* Code tabs */}
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
))}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={`text-xs font-mono transition-colors shrink-0 ${
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
</button>
),
)}
<div className="flex-1" />
{/* Preview / code toggle */}
{[
@@ -770,7 +827,41 @@ ${jsx}
{/* Main content area */}
<div className="flex-1 min-h-0 overflow-auto">
{mobileView === "catalog" ? (
{mobileView === "visual" ? (
currentTree ? (
<JsonEditor
value={currentTree as unknown as JsonValue}
onChange={handleVisualChange}
readOnly={isStreaming}
sidebarOpen={false}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{"// generate a spec to edit visually"}
</div>
)
) : mobileView === "catalog" ? (
<div className="h-full flex flex-col text-sm">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+27
View File
@@ -27,13 +27,16 @@ export const docsNavigation: NavSection[] = [
{ title: "Schemas", href: "/docs/schemas" },
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Computed Values", href: "/docs/computed-values" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Watchers", href: "/docs/watchers" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Rendering",
items: [
{ title: "Renderers", href: "/docs/renderers" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Generation Modes", href: "/docs/generation-modes" },
@@ -57,11 +60,31 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
external: true,
},
{
title: "React PDF",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
{
title: "Image",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
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,
},
],
},
{
@@ -86,8 +109,12 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/image", href: "/docs/api/image" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { PAGE_TITLES } from "./page-titles";
const DESCRIPTION =
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
export function pageMetadata(slug: string): Metadata {
const title = PAGE_TITLES[slug];
if (!title) return {};
const displayTitle = title.replace(/\n/g, " ");
const fullTitle = `${displayTitle} | json-render`;
const ogImageUrl = slug ? `/og/${slug}` : "/og";
return {
title: displayTitle,
openGraph: {
type: "website",
locale: "en_US",
siteName: "json-render",
title: fullTitle,
description: DESCRIPTION,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: `${displayTitle} - json-render`,
},
],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description: DESCRIPTION,
images: [ogImageUrl],
},
};
}
+7
View File
@@ -23,7 +23,10 @@ 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/renderers": "Renderers",
"docs/generation-modes": "Generation Modes",
"docs/code-export": "Code Export",
"docs/custom-schema": "Custom Schema & Renderer",
@@ -38,9 +41,13 @@ 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",
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
};
/**
+3 -1
View File
@@ -22,4 +22,6 @@ const nextConfig = {
const withMDX = createMDX({});
export default withMDX(nextConfig);
/** @type {import('next').NextConfig} */
const config = withMDX(nextConfig);
export default config;
+7 -5
View File
@@ -1,11 +1,11 @@
{
"name": "web",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -28,11 +28,13 @@
"@upstash/redis": "^1.36.1",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"@visual-json/react": "0.1.1",
"ai": "^6.0.33",
"bash-tool": "1.3.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
"just-bash": "2.9.6",
"lucide-react": "^0.562.0",
"next": "16.1.1",
@@ -48,11 +50,11 @@
"tailwind-merge": "^3.4.0",
"unist-util-visit": "5.1.0",
"vaul": "^1.1.2",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/mdx": "^2.0.13",
"@types/node": "^22.15.3",
Binary file not shown.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"extends": "@repo/typescript-config/nextjs.json",
"extends": "@internal/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [
{
+37
View File
@@ -0,0 +1,37 @@
# example-chat
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
- @json-render/shadcn@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/shadcn@0.9.0
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+13 -101
View File
@@ -1,5 +1,6 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
// =============================================================================
@@ -45,48 +46,19 @@ const mesh3DProps = {
*/
export const explorerCatalog = defineCatalog(schema, {
components: {
// Layout
Stack: {
props: z.object({
direction: z.enum(["horizontal", "vertical"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description: "Flex layout container",
example: { direction: "vertical", gap: "md", wrap: null },
},
Card: {
props: z.object({
title: z.string().nullable(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Card container with optional title and description",
example: { title: "Weather", description: "Current conditions" },
},
Grid: {
props: z.object({
columns: z.enum(["1", "2", "3", "4"]).nullable(),
gap: z.enum(["sm", "md", "lg"]).nullable(),
}),
slots: ["default"],
description: "Responsive grid layout container",
example: { columns: "3", gap: "md" },
},
// Typography
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Section heading",
example: { text: "Data Explorer", level: "h1" },
},
// From @json-render/shadcn (used as-is)
Stack: shadcnComponentDefinitions.Stack,
Card: shadcnComponentDefinitions.Card,
Grid: shadcnComponentDefinitions.Grid,
Heading: shadcnComponentDefinitions.Heading,
Separator: shadcnComponentDefinitions.Separator,
Accordion: shadcnComponentDefinitions.Accordion,
Progress: shadcnComponentDefinitions.Progress,
Skeleton: shadcnComponentDefinitions.Skeleton,
Badge: shadcnComponentDefinitions.Badge,
Alert: shadcnComponentDefinitions.Alert,
// Chat-specific components (different schemas or fully custom)
Text: {
props: z.object({
content: z.string(),
@@ -96,32 +68,6 @@ export const explorerCatalog = defineCatalog(schema, {
example: { content: "Here is your data overview." },
},
// Data display
Badge: {
props: z.object({
text: z.string(),
variant: z
.enum(["default", "secondary", "destructive", "outline"])
.nullable(),
}),
description: "Status badge",
example: { text: "Live", variant: "default" },
},
Alert: {
props: z.object({
variant: z.enum(["default", "destructive"]).nullable(),
title: z.string(),
description: z.string().nullable(),
}),
description: "Alert or info message",
},
Separator: {
props: z.object({}),
description: "Visual divider",
},
Metric: {
props: z.object({
label: z.string(),
@@ -222,22 +168,6 @@ export const explorerCatalog = defineCatalog(schema, {
description: "Content for a specific tab",
},
Progress: {
props: z.object({
value: z.number(),
max: z.number().nullable(),
}),
description: "Progress bar",
},
Skeleton: {
props: z.object({
width: z.string().nullable(),
height: z.string().nullable(),
}),
description: "Loading placeholder",
},
// Educational / Rich content
Callout: {
props: z.object({
@@ -254,24 +184,6 @@ export const explorerCatalog = defineCatalog(schema, {
},
},
Accordion: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
content: z.string(),
}),
),
type: z.enum(["single", "multiple"]).nullable(),
}),
description:
"Collapsible accordion sections for organizing detailed content",
example: {
items: [{ title: "Overview", content: "A brief introduction." }],
type: "multiple",
},
},
Timeline: {
props: z.object({
items: z.array(
+13 -111
View File
@@ -2,6 +2,7 @@
import { useState, useRef, type ReactNode } from "react";
import { useBoundProp, defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import {
Bar,
BarChart as RechartsBarChart,
@@ -20,16 +21,6 @@ import {
type ChartConfig,
} from "@/components/ui/chart";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Table,
TableHeader,
@@ -39,14 +30,6 @@ import {
TableCell,
} from "@/components/ui/table";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
Accordion as AccordionRoot,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import { Progress } from "@/components/ui/progress";
import { Skeleton } from "@/components/ui/skeleton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
@@ -218,77 +201,25 @@ function AnimatedGroup({
export const { registry, handlers } = defineRegistry(explorerCatalog, {
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${props.wrap ? "flex-wrap" : ""} ${gapClass}`}
>
{children}
</div>
);
},
Card: ({ props, children }) => (
<Card>
{(props.title || props.description) && (
<CardHeader>
{props.title && <CardTitle>{props.title}</CardTitle>}
{props.description && (
<CardDescription>{props.description}</CardDescription>
)}
</CardHeader>
)}
<CardContent className="flex flex-col gap-4">{children}</CardContent>
</Card>
),
Grid: ({ props, children }) => {
const colsClass =
{
"1": "grid-cols-1",
"2": "grid-cols-1 md:grid-cols-2",
"3": "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
"4": "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
}[props.columns ?? "3"] ?? "grid-cols-1 md:grid-cols-2 lg:grid-cols-3";
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return <div className={`grid ${colsClass} ${gapClass}`}>{children}</div>;
},
Heading: ({ props }) => {
const Tag = (props.level ?? "h2") as "h1" | "h2" | "h3" | "h4";
const sizeClass = {
h1: "text-3xl font-bold tracking-tight",
h2: "text-2xl font-semibold tracking-tight",
h3: "text-xl font-semibold",
h4: "text-lg font-medium",
}[props.level ?? "h2"];
return <Tag className={sizeClass}>{props.text}</Tag>;
},
// From @json-render/shadcn (used as-is)
Stack: shadcnComponents.Stack,
Card: shadcnComponents.Card,
Grid: shadcnComponents.Grid,
Heading: shadcnComponents.Heading,
Separator: shadcnComponents.Separator,
Accordion: shadcnComponents.Accordion,
Progress: shadcnComponents.Progress,
Skeleton: shadcnComponents.Skeleton,
Badge: shadcnComponents.Badge,
Alert: shadcnComponents.Alert,
// Chat-specific components
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Metric: ({ props }) => {
const TrendIcon =
props.trend === "up"
@@ -566,16 +497,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
<TabsContent value={props.value}>{children}</TabsContent>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Callout: ({ props }) => {
const config = {
info: {
@@ -626,23 +547,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
);
},
Accordion: ({ props }) => (
<AccordionRoot
type={props.type === "single" ? "single" : "multiple"}
collapsible={props.type === "single" ? true : undefined}
className="w-full"
>
{(props.items ?? []).map((item, i) => (
<AccordionItem key={i} value={`item-${i}`}>
<AccordionTrigger>{item.title}</AccordionTrigger>
<AccordionContent>
<p className="text-muted-foreground">{item.content}</p>
</AccordionContent>
</AccordionItem>
))}
</AccordionRoot>
),
Timeline: ({ props }) => (
<div className="relative pl-8">
{/* Vertical line centered on dots: dot is 12px wide starting at 0px, center = 6px */}
@@ -987,8 +891,6 @@ export const { registry, handlers } = defineRegistry(explorerCatalog, {
</DreiText>
),
},
actions: {},
});
// =============================================================================
+5 -4
View File
@@ -1,10 +1,10 @@
{
"name": "example-chat",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless chat-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -15,6 +15,7 @@
"@ai-sdk/react": "^3.0.84",
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"@streamdown/code": "^1.0.2",
@@ -32,10 +33,10 @@
"streamdown": "^2.2.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0",
"zod": "4.3.5"
"zod": "4.3.6"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+37
View File
@@ -0,0 +1,37 @@
# example-dashboard
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/codegen@0.11.0
- @json-render/react@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+21 -17
View File
@@ -196,24 +196,28 @@ export function Widget({
onDeleted?.();
}, [widgetId, onDeleted]);
const handleStateChange = useCallback((path: string, value: unknown) => {
setState((prev) => {
const next = { ...prev };
// Convert path like "customerForm/name" to nested object
const parts = path.split("/");
let current: Record<string, unknown> = next;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
const handleStateChange = useCallback(
(changes: Array<{ path: string; value: unknown }>) => {
setState((prev) => {
const next = { ...prev };
for (const { path, value } of changes) {
const parts = path.split("/");
let current: Record<string, unknown> = next;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i]!;
if (!(part in current) || typeof current[part] !== "object") {
current[part] = {};
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1]!;
current[lastPart] = value;
}
current = current[part] as Record<string, unknown>;
}
const lastPart = parts[parts.length - 1]!;
current[lastPart] = value;
return next;
});
}, []);
return next;
});
},
[],
);
// Use spec from stream, or initial spec for saved widgets
const currentSpec = spec || initialSpec;
+1 -1
View File
@@ -1,4 +1,4 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
import { nextJsConfig } from "@internal/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
+1 -1
View File
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
spec: Spec | null;
state?: Record<string, unknown>;
setState?: SetState;
onStateChange?: (path: string, value: unknown) => void;
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
loading?: boolean;
}
+4 -4
View File
@@ -1,10 +1,10 @@
{
"name": "example-dashboard",
"version": "0.1.0",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless dashboard-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -39,10 +39,10 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
+9
View File
@@ -0,0 +1,9 @@
# example-image
## 0.1.1
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/image@0.11.0
+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.1",
"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"]
}
+37
View File
@@ -0,0 +1,37 @@
# example-no-ai
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
- @json-render/shadcn@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/shadcn@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/shadcn@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/shadcn@0.9.0
+105
View File
@@ -0,0 +1,105 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/shadcn/src/**/*.tsx";
@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);
}
: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);
}
.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);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
button {
cursor: pointer;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "json-render No-AI Example",
description: "Static JSON specs rendered with json-render -- no AI required",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
</body>
</html>
);
}
+90
View File
@@ -0,0 +1,90 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import { JSONUIProvider, Renderer } from "@json-render/react";
import type { Spec } from "@json-render/core";
import {
registry,
actionHandlers,
computedFunctions,
onConfetti,
} from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<JSONUIProvider
registry={registry}
initialState={spec.state ?? {}}
handlers={actionHandlers}
functions={computedFunctions}
>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
);
}
export default function Page() {
const [selectedIndex, setSelectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
const fireConfetti = useCallback(() => {
setConfettiKey((k) => k + 1);
setConfettiActive(true);
}, []);
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex flex-col bg-muted/30">
{/* Example selector */}
<nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
{examples.map((ex, i) => (
<button
key={ex.name}
onClick={() => setSelectedIndex(i)}
className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
i === selectedIndex
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
{ex.name}
</button>
))}
</nav>
{/* Render area */}
<div className="flex-1 flex items-start justify-center overflow-auto p-6">
<div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="p-6 relative z-10">
<p className="text-xs text-muted-foreground mb-4">
{selected.description}
</p>
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
</div>
);
}
+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",
"react/no-unknown-property": ["error", { ignore: ["jsx"] }],
},
},
];
+860
View File
@@ -0,0 +1,860 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "Confetti",
description: "A button that fires confetti when clicked",
spec: {
root: "btn",
elements: {
btn: {
type: "Button",
props: { label: "Click me", variant: "primary" },
on: { press: { action: "confetti" } },
},
},
},
},
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "React", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, switches, and checkboxes",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: {
title: "Free",
description: "$0/month",
maxWidth: null,
centered: null,
},
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary", disabled: null },
},
pro: {
type: "Card",
props: {
title: "Pro",
description: "$19/month",
maxWidth: null,
centered: null,
},
children: ["proContent"],
},
proContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: {
label: "Upgrade to Pro",
variant: "primary",
disabled: null,
},
},
enterprise: {
type: "Card",
props: {
title: "Enterprise",
description: "Custom pricing",
maxWidth: null,
centered: null,
},
children: ["entContent"],
},
entContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: {
label: "Contact Sales",
variant: "secondary",
disabled: null,
},
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
{
name: "Feature Comparison",
description: "Tables inside an accordion for comparing plan tiers",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["heading", "subtitle", "accordion"],
},
heading: {
type: "Heading",
props: { text: "Feature Comparison", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Expand each section to see how plans compare.",
variant: "muted",
},
},
accordion: {
type: "Accordion",
props: {
items: [
{
title: "Projects & Team",
content:
"Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
},
{
title: "Storage & Bandwidth",
content:
"Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
},
{
title: "Support & SLA",
content:
"Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
},
],
type: "single",
},
},
},
},
},
// =========================================================================
// Advanced: Registration form with cross-field validation & $template
// =========================================================================
{
name: "Registration Form",
description:
"Cross-field validation, $template preview, and validateForm action",
spec: {
root: "card",
state: {
form: {
name: "",
email: "",
password: "",
confirmPassword: "",
accountType: "personal",
company: "",
},
result: null,
},
elements: {
card: {
type: "Card",
props: {
title: "Create Account",
description: "Fill out the form below to register",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"preview",
"sep0",
"nameInput",
"emailInput",
"passwordInput",
"confirmInput",
"sep1",
"accountTypeRadio",
"companyInput",
"sep2",
"actions",
"statusText",
],
},
// $template live preview
preview: {
type: "Text",
props: {
text: {
$template: "Welcome, ${/form/name}! Your email: ${/form/email}",
},
variant: "muted",
},
visible: { $state: "/form/name", neq: "" },
children: [],
},
sep0: { type: "Separator", props: { orientation: null }, children: [] },
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Jane Doe",
value: { $bindState: "/form/name" },
checks: [
{ type: "required", message: "Name is required" },
{
type: "minLength",
args: { min: 2 },
message: "Name must be at least 2 characters",
},
],
validateOn: "blur",
},
children: [],
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "jane@example.com",
value: { $bindState: "/form/email" },
checks: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email address" },
],
validateOn: "blur",
},
children: [],
},
passwordInput: {
type: "Input",
props: {
label: "Password",
name: "password",
type: "password",
placeholder: "At least 8 characters",
value: { $bindState: "/form/password" },
checks: [
{ type: "required", message: "Password is required" },
{
type: "minLength",
args: { min: 8 },
message: "Password must be at least 8 characters",
},
],
validateOn: "blur",
},
children: [],
},
confirmInput: {
type: "Input",
props: {
label: "Confirm Password",
name: "confirmPassword",
type: "password",
placeholder: "Re-enter your password",
value: { $bindState: "/form/confirmPassword" },
checks: [
{ type: "required", message: "Please confirm your password" },
{
type: "matches",
args: { other: { $state: "/form/password" } },
message: "Passwords must match",
},
],
validateOn: "blur",
},
children: [],
},
sep1: { type: "Separator", props: { orientation: null }, children: [] },
accountTypeRadio: {
type: "Radio",
props: {
label: "Account Type",
name: "accountType",
options: ["personal", "business"],
value: { $bindState: "/form/accountType" },
checks: null,
validateOn: null,
},
children: [],
},
companyInput: {
type: "Input",
props: {
label: "Company Name",
name: "company",
type: "text",
placeholder: "Acme Inc.",
value: { $bindState: "/form/company" },
checks: [
{
type: "requiredIf",
args: { field: { $state: "/form/accountType" } },
message: "Company name is required for business accounts",
},
],
validateOn: "blur",
},
visible: { $state: "/form/accountType", eq: "business" },
children: [],
},
sep2: { type: "Separator", props: { orientation: null }, children: [] },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["submitBtn"],
},
submitBtn: {
type: "Button",
props: { label: "Register", variant: "primary", disabled: null },
on: {
press: [
{
action: "validateForm",
params: { statePath: "/result" },
},
],
},
children: [],
},
// Validation result
statusText: {
type: "Alert",
props: {
title: "Validation Result",
message: {
$cond: { $state: "/result/valid", eq: true },
$then: "All fields are valid -- ready to submit!",
$else: "Please fix the errors above before submitting.",
},
type: {
$cond: { $state: "/result/valid", eq: true },
$then: "success",
$else: "error",
},
},
visible: { $state: "/result", neq: null },
children: [],
},
},
},
},
// =========================================================================
// Advanced: Cascading selects with watchers & $computed
// =========================================================================
{
name: "Cascading Selects",
description:
"Watchers reset dependent fields, $computed derives display values",
spec: {
root: "card",
state: {
form: { country: "", city: "" },
availableCities: [],
},
elements: {
card: {
type: "Card",
props: {
title: "Shipping Address",
description: "Select your country to load available cities",
maxWidth: "md",
centered: null,
},
children: ["formStack"],
},
formStack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"countrySelect",
"citySelect",
"sep",
"addressPreview",
"templatePreview",
],
},
countrySelect: {
type: "Select",
props: {
label: "Country",
name: "country",
options: ["US", "Canada", "UK", "Germany", "Japan"],
placeholder: "Choose a country",
value: { $bindState: "/form/country" },
checks: [{ type: "required", message: "Country is required" }],
validateOn: "change",
},
watch: {
"/form/country": [
{
action: "setState",
params: {
statePath: "/availableCities",
value: {
$computed: "citiesForCountry",
args: { country: { $state: "/form/country" } },
},
},
},
{
action: "setState",
params: { statePath: "/form/city", value: "" },
},
],
},
children: [],
},
citySelect: {
type: "Select",
props: {
label: "City",
name: "city",
options: { $state: "/availableCities" },
placeholder: "Select a city",
value: { $bindState: "/form/city" },
checks: [{ type: "required", message: "City is required" }],
validateOn: "change",
},
children: [],
},
sep: { type: "Separator", props: { orientation: null }, children: [] },
// $computed formatted address
addressPreview: {
type: "Heading",
props: {
text: {
$computed: "formatAddress",
args: {
city: { $state: "/form/city" },
country: { $state: "/form/country" },
},
},
level: "h3",
},
children: [],
},
// $template string interpolation
templatePreview: {
type: "Text",
props: {
text: {
$template:
"Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
},
variant: "muted",
},
visible: { $state: "/form/country", neq: "" },
children: [],
},
},
},
},
];
+25
View File
@@ -0,0 +1,25 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
},
actions: {
confetti: {
params: z.object({}),
description: "Fire confetti",
},
},
functions: {
formatAddress: {
description:
"Formats country and city into a single address string like 'City, Country'",
},
citiesForCountry: {
description: "Returns an array of city names for the given country code",
},
},
});
+55
View File
@@ -0,0 +1,55 @@
"use client";
import { defineRegistry } from "@json-render/react";
import type { ComputedFunction } from "@json-render/core";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
let confettiListener: (() => void) | null = null;
export function onConfetti(cb: () => void) {
confettiListener = cb;
return () => {
confettiListener = null;
};
}
const cityData: Record<string, string[]> = {
US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
};
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
},
actions: {
confetti: async () => {
confettiListener?.();
},
},
});
export const actionHandlers: Record<
string,
(params: Record<string, unknown>) => void
> = {
confetti: () => confettiListener?.(),
};
export const computedFunctions: Record<string, ComputedFunction> = {
formatAddress: (args) => {
const city = (args.city as string) ?? "";
const country = (args.country as string) ?? "";
if (!city && !country) return "No location selected";
if (!city) return country;
return `${city}, ${country}`;
},
citiesForCountry: (args) => {
const country = (args.country as string) ?? "";
return cityData[country] ?? [];
},
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+40
View File
@@ -0,0 +1,40 @@
{
"name": "example-no-ai",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"dev": "portless no-ai-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-confetti-explosion": "^3.0.3",
"tailwind-merge": "^3.4.0",
"zod": "4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"eslint": "^9.39.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+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"]
}
+33
View File
@@ -0,0 +1,33 @@
# example-react-native
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react-native@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react-native@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react-native@0.9.1
- @json-render/core@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react-native@0.9.0
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.0",
"version": "0.1.4",
"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",
+5
View File
@@ -0,0 +1,5 @@
# Required: AI Gateway API key
AI_GATEWAY_API_KEY=
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
# AI_GATEWAY_MODEL=openai/gpt-4o-mini

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