Compare commits

...
Author SHA1 Message Date
github-actions[bot] 96c7a452b3 chore: version packages (#71)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 14:33:12 -06:00
Chris Tate 844f18d1ee fix deps (#70) 2026-02-06 14:32:10 -06:00
Chris Tate 54bce097a5 add defineRegistry function (#67) 2026-02-06 14:30:06 -06:00
Chris Tate e2cb239c88 fix version (#69) 2026-02-06 14:28:38 -06:00
Chris Tate 90c59c674d update registry page (#68) 2026-02-06 14:27:07 -06:00
Chris Tate bba38727e8 ignore private packages (#66)
* ignore private packages

* fix ignore

* better ignore
2026-02-06 14:19:34 -06:00
Chris Tate 1d547b579c changeset (#65) 2026-02-06 14:12:00 -06:00
Chris Tate 795cdaac56 defineRegistry (#64)
* defineRegistry

* update dashboard example
2026-02-06 13:56:48 -06:00
Chris Tate 4ae049f218 stripe app (#62)
* stripe-app example

* fix lint

* add readme

* fix lint
2026-02-05 12:53:58 -06:00
Chris Tate f004caf95e combine docs nav (#61) 2026-02-05 10:22:51 -06:00
Chris Tate 72d6468e70 redirect for /docs/registry (#60) 2026-02-05 10:13:26 -06:00
Chris Tate e6f2b1e9b8 chore: bump @json-render/remotion to v0.4.1 (#59) 2026-02-05 05:05:21 -06:00
Chris Tate 85f82590a8 add motion to remotion (#58)
* add motion to remotion

* update example
2026-02-05 05:03:28 -06:00
Chris Tate 6129f312ea v0.4.0 (#57)
* v0.4.0

* slots
2026-02-05 04:15:00 -06:00
Chris Tate c1b1aaaddb custom schema system (#56)
* add rate limits

* fix lint

* better

generate prompt

update examples

* fixes

* fixes

* new api

* update docs

* fixes

* fixes

* generated prompt

* Add tests for catalog validation and prompt generation

- Test generateSystemPrompt with components, actions, custom rules
- Test new defineCatalog API from schema system
- Test catalog.prompt() method with custom rules
- Test catalog.validate() for valid and invalid specs
- Test catalog.zodSchema() for custom validation
- Test catalog.jsonSchema() for structured outputs
- Add tests for nested specs with children
- Add tests for rejecting invalid component types

* Fix lint: pass children as nested elements in renderer

Change from children={...} prop to nested children pattern
to satisfy react/no-children-prop rule.

* Fix lint errors in dashboard and web apps

- Disable react/prop-types in both eslint configs (TypeScript handles this)
- Allow styled-jsx 'jsx' property in dashboard
- Add DATABASE_URL to turbo env allowlist
- Remove unused drizzle-orm imports (and, sql)
- Suppress unused variable warnings where intentional

* Add server entry point for @json-render/remotion

The main package entry includes React components that require
client-side context (React.createContext). This causes build
failures when importing in server-side API routes.

Added `@json-render/remotion/server` entry point that exports
only schema and catalog definitions without React dependencies:
- schema, RemotionSchema, RemotionSpec
- standardComponentDefinitions, standardTransitionDefinitions
- standardEffectDefinitions
- Type exports for catalogs

Updated remotion example to import from /server in catalog.ts

* Make dashboard database connection lazy-initialized

The database connection threw an error at module load time if
DATABASE_URL was not set, causing builds to fail in CI where
the database is not available.

Changed to lazy initialization using a Proxy so the error only
occurs when the database is actually used at runtime, not at
build time.

* Fix previousSpec property name and lazy rate limiting

- Fix property name mismatch: playground now passes previousSpec
  instead of previousTree to match what useUIStream hook expects
- Make rate limiting lazy-initialized to avoid runtime errors when
  Redis env vars (KV_REST_API_URL, KV_REST_API_TOKEN) are not set
- Rate limiting gracefully becomes a no-op when Redis is unavailable
2026-02-05 03:59:56 -06:00
Chris Tate a48f6d665d add rate limits (#46)
* add rate limits

* fix lint
2026-01-25 13:37:03 -06:00
Chris Tate 06235798e1 ui fixes (#41) 2026-01-21 13:55:17 -06:00
Chris Tate e323d263d0 add playground (#40)
* add playground

* update title
2026-01-21 13:46:52 -06:00
Chris Tate 75ab55f178 fix header (#38) 2026-01-21 12:18:42 -06:00
Chris Tate 43756783fe make model configurable (#17)
* make model configurable

* update examples

* fix lint

* update env var name

* fix
2026-01-16 22:45:16 -06:00
Chris Tate af9dd04085 codegen (#15)
* codegen

* fixes

* fix lint

* address feedback
2026-01-16 22:10:07 -06:00
gui de andrade 96a6ce112c fix: update AI Gateway API key link (#14) 2026-01-16 16:52:00 -06:00
Chris Tate 297e2bc3eb fix .env.example (#13) 2026-01-16 12:53:27 -06:00
Yogesh Khutwad a6db3df2ae fix: improve alert readability in dark mode (#6) 2026-01-16 11:14:43 -06:00
Shiven Aggarwal e7c78a8302 fix: prevent horizontal overflow on mobile (#4) 2026-01-16 10:41:23 -06:00
Chris Tate 4f36dfb625 more doc improvements 2026-01-14 22:04:58 -06:00
Chris Tate b3d089ebad fix docs menu 2026-01-14 22:01:36 -06:00
Chris Tate 0199896b6d fix og 2026-01-14 18:51:57 -06:00
Chris Tate f41833f607 fix seo 2026-01-14 18:48:48 -06:00
Chris Tate 7e059962f6 0.2.0 2026-01-14 18:44:51 -06:00
Chris Tate 3141cc1881 upgrade to zod 4 2026-01-14 18:43:20 -06:00
Chris Tate 1336489352 add unit tests 2026-01-14 18:38:50 -06:00
Chris Tate 37bb0b0408 fix check 2026-01-14 18:32:10 -06:00
Chris Tate 6b2f581e52 fixes 2026-01-14 18:30:38 -06:00
Chris Tate fdabc52838 Merge branch 'main' of https://github.com/vercel-labs/json-render 2026-01-14 18:29:51 -06:00
Chris Tate 005b27eccc fix lint errors 2026-01-14 18:29:39 -06:00
Chris Tate 85ecc3af25 Merge pull request #2 from vercel-labs/vercel/install-vercel-speed-insights-k54cvl
Install Vercel Speed Insights for Next.js
2026-01-14 18:23:53 -06:00
Vercel f41bef4047 Install Vercel Speed Insights for Next.js
## Vercel Speed Insights Integration

Successfully implemented Vercel Speed Insights for the json-render project.

### Changes Made

#### 1. Installation
- Installed `@vercel/speed-insights@^1.3.1` using pnpm package manager
- Updated `apps/web/package.json` with the new dependency
- Updated `pnpm-lock.yaml` to reflect the new dependency

#### 2. Configuration
- **File Modified**: `apps/web/app/layout.tsx`
- Added import: `import { SpeedInsights } from "@vercel/speed-insights/next"`
- Added component: `<SpeedInsights />` in the body tag after the `<Analytics />` component
- This follows Next.js 13.5+ with App Router best practices

### Implementation Details

The project uses:
- **Next.js version**: 16.1.1 (with App Router)
- **Package manager**: pnpm (v9.0.0)
- **Router type**: App Router (`app/` directory)

Since the project uses Next.js 16.1.1 with App Router, the implementation uses:
- `@vercel/speed-insights/next` import (for Next.js 13.5+)
- Direct component placement in the root layout (no 'use client' directive needed)
- Positioned after the Analytics component in the body tag

### Testing

✓ Build completed successfully (all 4 workspace projects compiled)
✓ ESLint passed with no warnings or errors
✓ No TypeScript errors or type conflicts
✓ All dependencies properly installed

### Files Changed

1. `apps/web/package.json` - Added @vercel/speed-insights dependency
2. `apps/web/app/layout.tsx` - Added SpeedInsights component and import
3. `pnpm-lock.yaml` - Updated lock file with new dependency

The implementation is minimal, non-breaking, and follows the existing project patterns (similar to how @vercel/analytics is already integrated).

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-01-15 00:23:02 +00:00
Chris Tate a6bf3ef3da format 2026-01-14 18:19:57 -06:00
Chris Tate ec75115461 format 2026-01-14 18:19:43 -06:00
Chris Tate 867a9e98c4 ci 2026-01-14 18:17:20 -06:00
Chris Tate b5e7251ad8 type-check after each turn 2026-01-14 18:16:24 -06:00
Chris Tate 995c2ccfa5 Merge pull request #1 from vercel-labs/vercel/vercel-web-analytics-to-nextjs-7a7zb0
Add Vercel Web Analytics to Next.js
2026-01-14 18:16:13 -06:00
356 changed files with 53937 additions and 4331 deletions
+13
View File
@@ -0,0 +1,13 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
## Adding a changeset
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
packages to the same version.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@json-render/core",
"@json-render/react",
"@json-render/remotion",
"@json-render/codegen"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
}
}
+48
View File
@@ -0,0 +1,48 @@
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ci:
name: Lint, Type Check & Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Test
run: pnpm test
- name: Build
run: pnpm build
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
+1
View File
@@ -39,3 +39,4 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
+1
View File
@@ -0,0 +1 @@
pnpm lint-staged
+26
View File
@@ -2,9 +2,35 @@
Instructions for AI coding agents working with this codebase.
## Package Management
**Always check the latest version before installing a package.**
Before adding or updating any dependency, verify the current latest version on npm:
```bash
npm view <package-name> version
```
Or check multiple packages at once:
```bash
npm view ai version
npm view @ai-sdk/provider-utils version
npm view zod version
```
This ensures we don't install outdated versions that may have incompatible types or missing features.
## 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>`
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
<!-- opensrc:start -->
+140 -123
View File
@@ -2,10 +2,12 @@
**Predictable. Guardrailed. Fast.**
Let end users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.
Let end users generate dashboards, widgets, apps, and videos from prompts — safely constrained to components you define.
```bash
npm install @json-render/core @json-render/react
# or for video
npm install @json-render/core @json-render/remotion
```
## Why json-render?
@@ -18,83 +20,75 @@ When users prompt for UI, you need guarantees. json-render gives AI a **constrai
## Quick Start
### 1. Define Your Catalog (what AI can use)
### 1. Define Your Catalog
```typescript
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react";
import { z } from "zod";
const catalog = createCatalog({
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
hasChildren: true,
description: "A card container",
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // Binds to your data
format: z.enum(['currency', 'percent', 'number']),
value: z.string(),
format: z.enum(["currency", "percent", "number"]).nullable(),
}),
description: "Display a metric value",
},
Button: {
props: z.object({
label: z.string(),
action: ActionSchema, // AI declares intent, you handle it
action: z.string(),
}),
description: "Clickable button",
},
},
actions: {
export_report: { description: 'Export dashboard to PDF' },
refresh_data: { description: 'Refresh all metrics' },
export_report: { description: "Export dashboard to PDF" },
refresh_data: { description: "Refresh all metrics" },
},
});
```
### 2. Register Your Components (how they render)
### 2. Define Your Components
```tsx
const registry = {
Card: ({ element, children }) => (
<div className="card">
<h3>{element.props.title}</h3>
{children}
</div>
),
Metric: ({ element }) => {
const value = useDataValue(element.props.valuePath);
return <div className="metric">{format(value)}</div>;
import { defineRegistry, Renderer } from "@json-render/react";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action)}>
{element.props.label}
</button>
),
};
});
```
### 3. Let AI Generate
### 3. Render AI-Generated Specs
```tsx
import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
function Dashboard() {
const { tree, send } = useUIStream({ api: '/api/generate' });
return (
<DataProvider initialData={{ revenue: 125000, growth: 0.15 }}>
<ActionProvider actions={{
export_report: () => downloadPDF(),
refresh_data: () => refetch(),
}}>
<input
placeholder="Create a revenue dashboard..."
onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
/>
<Renderer tree={tree} components={registry} />
</ActionProvider>
</DataProvider>
);
function Dashboard({ spec }) {
return <Renderer spec={spec} registry={registry} />;
}
```
@@ -102,11 +96,93 @@ function Dashboard() {
---
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
## Renderers
### React (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
// Element tree spec format
const spec = {
root: {
type: "Card",
props: { title: "Hello" },
children: [
{ type: "Button", props: { label: "Click me" } }
]
}
};
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: { id: "video", fps: 30, width: 1920, height: 1080, durationInFrames: 300 },
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
],
audio: { tracks: [] }
};
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>
```
## Features
### Conditional Visibility
### Streaming (SpecStream)
Show/hide components based on data, auth, or complex logic:
Stream AI responses progressively:
```typescript
import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process chunks as they arrive
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result
// Get final result
const finalSpec = compiler.getResult();
```
### AI Prompt Generation
Generate system prompts from your catalog:
```typescript
const systemPrompt = catalog.prompt();
// Includes component descriptions, props schemas, available actions
```
### Conditional Visibility
```json
{
@@ -121,66 +197,20 @@ Show/hide components based on data, auth, or complex logic:
}
```
```json
{
"type": "AdminPanel",
"visible": { "auth": "signedIn" }
}
```
### Rich Actions
Actions with confirmation dialogs and callbacks:
### Data Binding
```json
{
"type": "Button",
"type": "Metric",
"props": {
"label": "Refund Payment",
"action": {
"name": "refund",
"params": {
"paymentId": { "path": "/selected/id" },
"amount": { "path": "/refund/amount" }
},
"confirm": {
"title": "Confirm Refund",
"message": "Refund ${/refund/amount} to customer?",
"variant": "danger"
},
"onSuccess": { "set": { "/ui/success": true } },
"onError": { "set": { "/ui/error": "$error.message" } }
}
}
}
```
### Built-in Validation
```json
{
"type": "TextField",
"props": {
"label": "Email",
"valuePath": "/form/email",
"checks": [
{ "fn": "required", "message": "Email is required" },
{ "fn": "email", "message": "Invalid email" }
],
"validateOn": "blur"
"label": "Revenue",
"value": "{{data.revenue}}"
}
}
```
---
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Types, schemas, visibility, actions, validation |
| `@json-render/react` | React renderer, providers, hooks |
## Demo
```bash
@@ -192,32 +222,19 @@ pnpm dev
- http://localhost:3000 — Docs & Playground
- http://localhost:3001 — Example Dashboard
## Project Structure
```
json-render/
├── packages/
│ ├── core/ → @json-render/core
│ └── react/ → @json-render/react
├── apps/
│ └── web/ → Docs & Playground site
└── examples/
└── dashboard/ → Example dashboard app
```
- http://localhost:3002 — Remotion Video Example
## How It Works
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ User Prompt │────▶│ AI + Catalog│────▶│ JSON Tree │
│ "dashboard" │ │ (guardrailed)│ │(predictable)│
└─────────────┘ └──────────────┘ └─────────────┘
│
┌──────────────┐ │
│ Your React │◀───────────┘
│ Components │ (streamed)
└──────────────┘
```mermaid
flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
```
1. **Define the guardrails** — what components, actions, and data bindings AI can use
+13 -2
View File
@@ -1,3 +1,14 @@
# AI Gateway API Key (required for /api/generate endpoint)
# Get your key from https://gateway.ai.cloudflare.com or your AI provider
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# AI Model Configuration
# Override the default model used for UI generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Vercel KV (Rate Limiting)
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
+1
View File
@@ -35,3 +35,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
+313
View File
@@ -0,0 +1,313 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "A2UI Integration | json-render",
};
export default function A2UIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">A2UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use <code className="text-foreground">@json-render/core</code> to
support{" "}
<a
href="https://a2ui.org"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
A2UI
</a>{" "}
natively.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support A2UI. The examples are illustrative and may require adaptation
for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. Define a catalog that matches A2UI&apos;s format and
build a renderer that understands it - no conversion layer needed.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Example A2UI Message</h2>
<p className="text-sm text-muted-foreground mb-4">
A2UI uses an adjacency list model - a flat list of components with ID
references. This makes it easy to patch individual components:
</p>
<Code lang="json">{`{
"surfaceUpdate": {
"surfaceId": "main",
"components": [
{
"id": "header",
"component": {
"Text": {
"text": {"literalString": "Book Your Table"},
"usageHint": "h1"
}
}
},
{
"id": "date-picker",
"component": {
"DateTimeInput": {
"label": {"literalString": "Select Date"},
"value": {"path": "/reservation/date"},
"enableDate": true
}
}
},
{
"id": "submit-btn",
"component": {
"Button": {
"child": "submit-text",
"action": {"name": "confirm_booking"}
}
}
},
{
"id": "submit-text",
"component": {
"Text": {"text": {"literalString": "Confirm Reservation"}}
}
}
]
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Catalog
</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// A2UI BoundValue schema
const BoundString = z.object({
literalString: z.string().optional(),
path: z.string().optional(),
}).refine(d => d.literalString || d.path);
// A2UI children schema
const Children = z.object({
explicitList: z.array(z.string()).optional(),
template: z.object({
dataBinding: z.string(),
componentId: z.string(),
}).optional(),
}).refine(d => d.explicitList || d.template);
export const a2uiCatalog = createCatalog({
components: {
Text: {
description: 'Displays text content',
props: z.object({
text: BoundString,
usageHint: z.enum(['h1', 'h2', 'h3', 'body', 'caption']).optional(),
}),
},
Button: {
description: 'Interactive button',
props: z.object({
child: z.string(),
action: z.object({
name: z.string(),
context: z.array(z.object({
key: z.string(),
value: BoundString,
})).optional(),
}).optional(),
}),
},
DateTimeInput: {
description: 'Date/time picker',
props: z.object({
label: BoundString.optional(),
value: BoundString.optional(),
enableDate: z.boolean().optional(),
enableTime: z.boolean().optional(),
}),
},
Column: {
description: 'Vertical layout',
props: z.object({
children: Children,
}),
},
Row: {
description: 'Horizontal layout',
props: z.object({
children: Children,
}),
},
// Add more A2UI standard components...
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define the schema for A2UI message types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Component instance in the adjacency list
const A2UIComponent = z.object({
id: z.string(),
component: z.record(z.record(z.unknown())),
});
// Surface update message
const SurfaceUpdate = z.object({
surfaceId: z.string().optional(),
components: z.array(A2UIComponent),
});
// Data model update message
const DataModelUpdate = z.object({
surfaceId: z.string().optional(),
path: z.string().optional(),
contents: z.array(z.object({
key: z.string(),
valueString: z.string().optional(),
valueNumber: z.number().optional(),
valueBoolean: z.boolean().optional(),
valueMap: z.array(z.unknown()).optional(),
})),
});
// Begin rendering message
const BeginRendering = z.object({
surfaceId: z.string().optional(),
root: z.string(),
catalogId: z.string().optional(),
});
// Complete A2UI message schema
export const A2UIMessage = z.object({
surfaceUpdate: SurfaceUpdate.optional(),
dataModelUpdate: DataModelUpdate.optional(),
beginRendering: BeginRendering.optional(),
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an A2UI Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes the A2UI adjacency list format:
</p>
<Code lang="tsx">{`import { a2uiCatalog } from './catalog';
// Component registry
const components = {
Text: ({ text, usageHint }) => {
const Tag = usageHint?.startsWith('h') ? usageHint : 'p';
return <Tag>{text}</Tag>;
},
Button: ({ children, action, onAction }) => (
<button onClick={() => onAction?.(action)}>{children}</button>
),
DateTimeInput: ({ label, value, onChange }) => (
<label>
{label}
<input type="date" value={value} onChange={e => onChange?.(e.target.value)} />
</label>
),
Column: ({ children }) => <div className="flex flex-col gap-2">{children}</div>,
Row: ({ children }) => <div className="flex gap-2">{children}</div>,
};
// Render A2UI surface
export function renderA2UI(
componentMap: Map<string, any>,
dataModel: Record<string, any>,
rootId: string,
onAction?: (action: any) => void
) {
function resolveBoundValue(bound: any) {
if (!bound) return undefined;
if (bound.literalString) return bound.literalString;
if (bound.path) {
const parts = bound.path.replace(/^\\//, '').split('/');
let value = dataModel;
for (const p of parts) value = value?.[p];
return value;
}
}
function render(id: string): React.ReactNode {
const comp = componentMap.get(id);
if (!comp) return null;
const [type, props] = Object.entries(comp.component)[0];
const Component = components[type];
if (!Component) return null;
// Resolve props
const resolved: any = {};
for (const [key, val] of Object.entries(props as any)) {
if (key === 'child') {
resolved.children = render(val as string);
} else if (key === 'children' && val?.explicitList) {
resolved.children = val.explicitList.map(render);
} else if (val && typeof val === 'object' && ('literalString' in val || 'path' in val)) {
resolved[key] = resolveBoundValue(val);
} else {
resolved[key] = val;
}
}
return <Component key={id} {...resolved} onAction={onAction} />;
}
return render(rootId);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
<Code lang="tsx">{`const [components] = useState(() => new Map());
const [dataModel, setDataModel] = useState({});
const [rootId, setRootId] = useState<string | null>(null);
// Process A2UI messages
function handleMessage(msg: any) {
if (msg.surfaceUpdate) {
for (const comp of msg.surfaceUpdate.components) {
components.set(comp.id, comp);
}
}
if (msg.dataModelUpdate) {
setDataModel(prev => ({ ...prev, ...msg.dataModelUpdate.contents }));
}
if (msg.beginRendering) {
setRootId(msg.beginRendering.root);
}
}
// Render
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards integration
</Link>{" "}
for another UI protocol.
</p>
</article>
);
}
@@ -0,0 +1,791 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Adaptive Cards Integration | json-render",
};
export default function AdaptiveCardsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to render{" "}
<a
href="https://adaptivecards.io"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Microsoft Adaptive Cards
</a>{" "}
natively.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support Adaptive Cards. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">
Adaptive Cards Overview
</h2>
<p className="text-sm text-muted-foreground mb-4">
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
Cards have a <code className="text-foreground">body</code> array of
elements and an optional{" "}
<code className="text-foreground">actions</code> array for interactive
buttons.
</p>
<h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
<Code lang="json">{`{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Hello, Adaptive Cards!",
"size": "large",
"weight": "bolder"
},
{
"type": "Image",
"url": "https://example.com/image.png",
"altText": "Example image"
},
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": "This is inside a container",
"wrap": true
}
]
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "auto",
"items": [
{ "type": "TextBlock", "text": "Column 1" }
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{ "type": "TextBlock", "text": "Column 2" }
]
}
]
},
{
"type": "Input.Text",
"id": "userInput",
"placeholder": "Enter your name",
"label": "Name"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
},
{
"type": "Action.OpenUrl",
"title": "Learn More",
"url": "https://adaptivecards.io"
}
]
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Creating an Adaptive Cards Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog matching the Adaptive Cards element types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// Common Adaptive Cards properties
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
const ImageStyle = z.enum(['default', 'person']);
// Base element properties shared by most elements
const BaseElement = {
id: z.string().optional(),
isVisible: z.boolean().optional(),
separator: z.boolean().optional(),
spacing: Spacing.optional(),
};
export const adaptiveCardsCatalog = createCatalog({
components: {
// Root card
AdaptiveCard: {
description: 'Root Adaptive Card container',
props: z.object({
version: z.string(),
body: z.array(z.unknown()).optional(),
actions: z.array(z.unknown()).optional(),
fallbackText: z.string().optional(),
minHeight: z.string().optional(),
rtl: z.boolean().optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
// Elements
TextBlock: {
description: 'Displays text with formatting options',
props: z.object({
...BaseElement,
text: z.string(),
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
fontType: z.enum(['default', 'monospace']).optional(),
horizontalAlignment: HorizontalAlignment.optional(),
isSubtle: z.boolean().optional(),
maxLines: z.number().optional(),
size: FontSize.optional(),
weight: FontWeight.optional(),
wrap: z.boolean().optional(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
...BaseElement,
url: z.string(),
altText: z.string().optional(),
backgroundColor: z.string().optional(),
height: z.string().optional(),
width: z.string().optional(),
horizontalAlignment: HorizontalAlignment.optional(),
size: ImageSize.optional(),
style: ImageStyle.optional(),
}),
},
Container: {
description: 'Groups elements together',
props: z.object({
...BaseElement,
items: z.array(z.unknown()),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
bleed: z.boolean().optional(),
minHeight: z.string().optional(),
}),
},
ColumnSet: {
description: 'Arranges columns horizontally',
props: z.object({
...BaseElement,
columns: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
minHeight: z.string().optional(),
}),
},
Column: {
description: 'A column within a ColumnSet',
props: z.object({
...BaseElement,
items: z.array(z.unknown()).optional(),
width: z.union([z.string(), z.number()]).optional(),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
FactSet: {
description: 'Displays a series of facts as key/value pairs',
props: z.object({
...BaseElement,
facts: z.array(z.object({
title: z.string(),
value: z.string(),
})),
}),
},
ImageSet: {
description: 'Displays a collection of images',
props: z.object({
...BaseElement,
images: z.array(z.object({
type: z.literal('Image'),
url: z.string(),
altText: z.string().optional(),
})),
imageSize: ImageSize.optional(),
}),
},
ActionSet: {
description: 'Displays a set of actions',
props: z.object({
...BaseElement,
actions: z.array(z.unknown()),
}),
},
RichTextBlock: {
description: 'Rich text with inline formatting',
props: z.object({
...BaseElement,
inlines: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
}),
},
// Inputs
'Input.Text': {
description: 'Text input field',
props: z.object({
...BaseElement,
id: z.string(),
isMultiline: z.boolean().optional(),
maxLength: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Number': {
description: 'Number input field',
props: z.object({
...BaseElement,
id: z.string(),
max: z.number().optional(),
min: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.number().optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Date': {
description: 'Date picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Time': {
description: 'Time picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Toggle': {
description: 'Toggle/checkbox input',
props: z.object({
...BaseElement,
id: z.string(),
title: z.string(),
label: z.string().optional(),
value: z.string().optional(),
valueOff: z.string().optional(),
valueOn: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.ChoiceSet': {
description: 'Dropdown or radio/checkbox group',
props: z.object({
...BaseElement,
id: z.string(),
choices: z.array(z.object({
title: z.string(),
value: z.string(),
})),
isMultiSelect: z.boolean().optional(),
style: z.enum(['compact', 'expanded']).optional(),
label: z.string().optional(),
value: z.string().optional(),
placeholder: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
// Actions
'Action.OpenUrl': {
description: 'Opens a URL',
props: z.object({
title: z.string().optional(),
url: z.string(),
iconUrl: z.string().optional(),
}),
},
'Action.Submit': {
description: 'Submits input data',
props: z.object({
title: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
'Action.ShowCard': {
description: 'Shows a card inline',
props: z.object({
title: z.string().optional(),
card: z.unknown(),
iconUrl: z.string().optional(),
}),
},
'Action.ToggleVisibility': {
description: 'Toggles visibility of elements',
props: z.object({
title: z.string().optional(),
targetElements: z.array(z.union([
z.string(),
z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
])),
iconUrl: z.string().optional(),
}),
},
'Action.Execute': {
description: 'Universal action for bots',
props: z.object({
title: z.string().optional(),
verb: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Building an Adaptive Cards Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes Adaptive Cards JSON:
</p>
<Code lang="tsx">{`'use client';
import React from 'react';
interface AdaptiveCardElement {
type: string;
[key: string]: unknown;
}
interface AdaptiveCard {
type: 'AdaptiveCard';
version: string;
body?: AdaptiveCardElement[];
actions?: AdaptiveCardElement[];
}
interface RenderContext {
onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
inputs: Record<string, unknown>;
setInput: (id: string, value: unknown) => void;
}
// Widget registry for Adaptive Cards elements
const widgets: Record<string, React.FC<any>> = {
TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
const sizeClass = {
small: 'text-xs',
default: 'text-sm',
medium: 'text-base',
large: 'text-lg',
extraLarge: 'text-2xl',
}[size || 'default'];
const weightClass = {
lighter: 'font-light',
default: 'font-normal',
bolder: 'font-bold',
}[weight || 'default'];
const alignClass = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[horizontalAlignment || 'left'];
return (
<p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
{text}
</p>
);
},
Image: ({ url, altText, size, style, horizontalAlignment }) => {
const sizeClass = {
auto: '',
stretch: 'w-full',
small: 'w-16',
medium: 'w-32',
large: 'w-48',
}[size || 'auto'];
return (
<div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
<img
src={url}
alt={altText || ''}
className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
/>
</div>
);
},
Container: ({ items, style, children, ctx }) => {
const styleClass = {
default: '',
emphasis: 'bg-muted p-2 rounded',
good: 'bg-green-50 p-2 rounded',
attention: 'bg-red-50 p-2 rounded',
warning: 'bg-yellow-50 p-2 rounded',
accent: 'bg-blue-50 p-2 rounded',
}[style || 'default'];
return (
<div className={\`\${styleClass} space-y-2\`}>
{children || items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
ColumnSet: ({ columns, ctx }) => (
<div className="flex gap-2">
{columns?.map((col: any, i: number) => (
<AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
))}
</div>
),
Column: ({ items, width, style, ctx }) => {
const widthClass = width === 'auto' ? 'flex-none' :
width === 'stretch' ? 'flex-1' :
typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
return (
<div className={\`\${widthClass} space-y-2\`}>
{items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
FactSet: ({ facts }) => (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{facts?.map((fact: any, i: number) => (
<React.Fragment key={i}>
<span className="font-medium">{fact.title}</span>
<span>{fact.value}</span>
</React.Fragment>
))}
</div>
),
ActionSet: ({ actions, ctx }) => (
<div className="flex gap-2 pt-2">
{actions?.map((action: any, i: number) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
),
'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{isMultiline ? (
<textarea
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
) : (
<input
type="text"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
)}
</div>
),
'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
min={min}
max={max}
defaultValue={value}
onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
/>
</div>
),
'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={id}
defaultChecked={value === valueOn}
onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
/>
<label htmlFor={id} className="text-sm">{title || label}</label>
</div>
),
'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{style === 'expanded' ? (
<div className="space-y-1">
{choices?.map((choice: any, i: number) => (
<label key={i} className="flex items-center gap-2 text-sm">
<input
type={isMultiSelect ? 'checkbox' : 'radio'}
name={id}
value={choice.value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
{choice.title}
</label>
))}
</div>
) : (
<select
className="w-full px-3 py-2 border rounded text-sm"
onChange={(e) => ctx.setInput(id, e.target.value)}
>
{placeholder && <option value="">{placeholder}</option>}
{choices?.map((choice: any, i: number) => (
<option key={i} value={choice.value}>{choice.title}</option>
))}
</select>
)}
</div>
),
'Action.Submit': ({ title, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
>
{title || 'Submit'}
</button>
),
'Action.OpenUrl': ({ title, url }) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 border rounded text-sm hover:bg-muted"
>
{title || 'Open'}
</a>
),
'Action.Execute': ({ title, verb, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
>
{title || 'Execute'}
</button>
),
};
function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
const Widget = widgets[element.type];
if (!Widget) {
console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
return null;
}
return <Widget {...element} ctx={ctx} />;
}
export function AdaptiveCardRenderer({
card,
onAction,
}: {
card: AdaptiveCard;
onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
}) {
const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
const ctx: RenderContext = {
onAction: onAction || (() => {}),
inputs,
setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
};
return (
<div className="rounded-lg border p-4 space-y-3 max-w-md">
{card.body?.map((element, i) => (
<AdaptiveElement key={i} element={element} ctx={ctx} />
))}
{card.actions && card.actions.length > 0 && (
<div className="flex gap-2 pt-2 border-t">
{card.actions.map((action, i) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<p className="text-sm text-muted-foreground mb-4">
Render an Adaptive Card and handle actions:
</p>
<Code lang="tsx">{`'use client';
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
const card = {
type: 'AdaptiveCard' as const,
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Contact Form',
size: 'large',
weight: 'bolder',
},
{
type: 'Input.Text',
id: 'name',
label: 'Your Name',
placeholder: 'Enter your name',
},
{
type: 'Input.Text',
id: 'message',
label: 'Message',
placeholder: 'Enter your message',
isMultiline: true,
},
],
actions: [
{
type: 'Action.Submit',
title: 'Send',
data: { action: 'submitForm' },
},
],
};
export function ContactCard() {
const handleAction = (action: any, inputData: Record<string, unknown>) => {
console.log('Action:', action);
console.log('Input data:', inputData);
// Send to your backend
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: inputData }),
});
};
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Handling Action.Execute for Bots
</h2>
<p className="text-sm text-muted-foreground mb-4">
For bot scenarios, handle{" "}
<code className="text-foreground">Action.Execute</code> with the verb
and data:
</p>
<Code lang="typescript">{`interface ActionExecutePayload {
action: {
type: 'Action.Execute';
verb: string;
data?: unknown;
};
inputs: Record<string, unknown>;
}
async function handleBotAction(payload: ActionExecutePayload) {
const response = await fetch('/api/bot/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verb: payload.action.verb,
data: payload.action.data,
inputs: payload.inputs,
}),
});
// Bot may return a new card to render
const result = await response.json();
if (result.card) {
return result.card; // New AdaptiveCard to render
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/a2ui" className="text-foreground hover:underline">
A2UI integration
</Link>{" "}
for another agent-driven UI protocol.
</p>
</article>
);
}
+651
View File
@@ -0,0 +1,651 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "AG-UI Integration | json-render",
};
export default function AGUIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">AG-UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to support{" "}
<a
href="https://docs.copilotkit.ai/ag-ui"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
AG-UI
</a>{" "}
(Agent User Interaction Protocol) from CopilotKit.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support AG-UI. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">What is AG-UI?</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI is an open protocol for connecting AI agents to user interfaces.
It provides a standardized way for agents to render UI components,
handle user input, and manage state. The protocol uses events streamed
over HTTP to update the UI in real-time.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">AG-UI Event Types</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI defines several event types for agent-UI communication:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">TEXT_MESSAGE_START</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_CONTENT</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_END</code> — Streaming
text messages
</li>
<li>
<code className="text-foreground">TOOL_CALL_START</code> /{" "}
<code className="text-foreground">TOOL_CALL_ARGS</code> /{" "}
<code className="text-foreground">TOOL_CALL_END</code> — Tool/function
calls
</li>
<li>
<code className="text-foreground">STATE_SNAPSHOT</code> /{" "}
<code className="text-foreground">STATE_DELTA</code> — State updates
</li>
<li>
<code className="text-foreground">CUSTOM</code> — Custom events for UI
rendering
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">
Example AG-UI Event Stream
</h3>
<Code lang="json">{`{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\\"component\\": \\"Dashboard\\", \\"props\\": {\\"title\\": \\"Sales\\"}}"}
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
{"type": "RUN_FINISHED"}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define schemas for AG-UI event types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Base event schema
const BaseEvent = z.object({
type: z.string(),
timestamp: z.number().optional(),
});
// Text message events
const TextMessageStart = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_START'),
messageId: z.string(),
role: z.enum(['user', 'assistant']),
});
const TextMessageContent = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_CONTENT'),
messageId: z.string(),
delta: z.string(),
});
const TextMessageEnd = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_END'),
messageId: z.string(),
});
// Tool call events
const ToolCallStart = BaseEvent.extend({
type: z.literal('TOOL_CALL_START'),
toolCallId: z.string(),
toolCallName: z.string(),
parentMessageId: z.string().optional(),
});
const ToolCallArgs = BaseEvent.extend({
type: z.literal('TOOL_CALL_ARGS'),
toolCallId: z.string(),
delta: z.string(),
});
const ToolCallEnd = BaseEvent.extend({
type: z.literal('TOOL_CALL_END'),
toolCallId: z.string(),
});
// State events
const StateSnapshot = BaseEvent.extend({
type: z.literal('STATE_SNAPSHOT'),
snapshot: z.record(z.unknown()),
});
const StateDelta = BaseEvent.extend({
type: z.literal('STATE_DELTA'),
delta: z.array(z.object({
op: z.enum(['add', 'remove', 'replace']),
path: z.string(),
value: z.unknown().optional(),
})),
});
// Custom event for UI components
const CustomEvent = BaseEvent.extend({
type: z.literal('CUSTOM'),
name: z.string(),
value: z.unknown(),
});
// Run lifecycle events
const RunStarted = BaseEvent.extend({
type: z.literal('RUN_STARTED'),
threadId: z.string(),
runId: z.string(),
});
const RunFinished = BaseEvent.extend({
type: z.literal('RUN_FINISHED'),
});
const RunError = BaseEvent.extend({
type: z.literal('RUN_ERROR'),
message: z.string(),
code: z.string().optional(),
});
// Union of all events
export const AGUIEvent = z.discriminatedUnion('type', [
TextMessageStart,
TextMessageContent,
TextMessageEnd,
ToolCallStart,
ToolCallArgs,
ToolCallEnd,
StateSnapshot,
StateDelta,
CustomEvent,
RunStarted,
RunFinished,
RunError,
]);
export type AGUIEvent = z.infer<typeof AGUIEvent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog for UI components that agents can render:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const aguiCatalog = createCatalog({
components: {
// Layout components
Container: {
description: 'A container for grouping elements',
props: z.object({
direction: z.enum(['row', 'column']).optional(),
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
}),
},
Card: {
description: 'A card with optional title',
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Content components
Text: {
description: 'Text content',
props: z.object({
content: z.string(),
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
}),
},
Markdown: {
description: 'Renders markdown content',
props: z.object({
content: z.string(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
src: z.string(),
alt: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
}),
},
// Data display
Table: {
description: 'Displays tabular data',
props: z.object({
columns: z.array(z.object({
key: z.string(),
header: z.string(),
width: z.string().optional(),
})),
data: z.array(z.record(z.unknown())),
}),
},
Chart: {
description: 'Renders a chart',
props: z.object({
type: z.enum(['line', 'bar', 'pie', 'area']),
data: z.array(z.record(z.unknown())),
xKey: z.string(),
yKey: z.string(),
title: z.string().optional(),
}),
},
Metric: {
description: 'Displays a metric value',
props: z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
change: z.number().optional(),
format: z.enum(['number', 'currency', 'percent']).optional(),
}),
},
// Input components
Button: {
description: 'Interactive button',
props: z.object({
label: z.string(),
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
disabled: z.boolean().optional(),
}),
},
Input: {
description: 'Text input field',
props: z.object({
name: z.string(),
label: z.string().optional(),
placeholder: z.string().optional(),
type: z.enum(['text', 'email', 'password', 'number']).optional(),
required: z.boolean().optional(),
}),
},
Select: {
description: 'Dropdown select',
props: z.object({
name: z.string(),
label: z.string().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string(),
})),
placeholder: z.string().optional(),
}),
},
Form: {
description: 'Form container',
props: z.object({
id: z.string(),
submitLabel: z.string().optional(),
}),
},
// Feedback components
Alert: {
description: 'Alert message',
props: z.object({
message: z.string(),
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
}),
},
Progress: {
description: 'Progress indicator',
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
}),
},
Skeleton: {
description: 'Loading placeholder',
props: z.object({
width: z.string().optional(),
height: z.string().optional(),
variant: z.enum(['text', 'circular', 'rectangular']).optional(),
}),
},
},
actions: {
submit: {
description: 'Submit form data',
params: z.object({
formId: z.string(),
}),
},
navigate: {
description: 'Navigate to a URL',
params: z.object({
url: z.string(),
}),
},
callback: {
description: 'Trigger a callback to the agent',
params: z.object({
name: z.string(),
data: z.record(z.unknown()).optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an AG-UI Event Processor
</h2>
<p className="text-sm text-muted-foreground mb-4">
Process AG-UI events and render UI components:
</p>
<Code lang="tsx">{`'use client';
import React, { useState, useCallback } from 'react';
import { AGUIEvent } from './schema';
interface AGUIState {
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
}>;
toolCalls: Map<string, {
name: string;
args: string;
result?: unknown;
}>;
state: Record<string, unknown>;
isRunning: boolean;
}
export function useAGUI() {
const [aguiState, setAGUIState] = useState<AGUIState>({
messages: [],
toolCalls: new Map(),
state: {},
isRunning: false,
});
const processEvent = useCallback((event: AGUIEvent) => {
switch (event.type) {
case 'RUN_STARTED':
setAGUIState(prev => ({ ...prev, isRunning: true }));
break;
case 'RUN_FINISHED':
setAGUIState(prev => ({ ...prev, isRunning: false }));
break;
case 'TEXT_MESSAGE_START':
setAGUIState(prev => ({
...prev,
messages: [...prev.messages, {
id: event.messageId,
role: event.role,
content: '',
}],
}));
break;
case 'TEXT_MESSAGE_CONTENT':
setAGUIState(prev => ({
...prev,
messages: prev.messages.map(msg =>
msg.id === event.messageId
? { ...msg, content: msg.content + event.delta }
: msg
),
}));
break;
case 'TOOL_CALL_START':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
return { ...prev, toolCalls };
});
break;
case 'TOOL_CALL_ARGS':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
const tc = toolCalls.get(event.toolCallId);
if (tc) {
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
}
return { ...prev, toolCalls };
});
break;
case 'STATE_SNAPSHOT':
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
break;
case 'STATE_DELTA':
setAGUIState(prev => {
const newState = { ...prev.state };
for (const op of event.delta) {
const parts = op.path.split('/').filter(Boolean);
if (op.op === 'replace' || op.op === 'add') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]] = obj[parts[i]] || {};
}
obj[parts[parts.length - 1]] = op.value;
} else if (op.op === 'remove') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]];
if (!obj) break;
}
if (obj) delete obj[parts[parts.length - 1]];
}
}
return { ...prev, state: newState };
});
break;
}
}, []);
return { state: aguiState, processEvent };
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Rendering Tool Call Results as UI
</h2>
<p className="text-sm text-muted-foreground mb-4">
When an agent calls a <code className="text-foreground">render_ui</code>{" "}
tool, parse the arguments and render the component:
</p>
<Code lang="tsx">{`import { aguiCatalog } from './catalog';
// Component registry
const components: Record<string, React.FC<any>> = {
Container: ({ direction = 'column', gap = 'md', children }) => (
<div className={\`flex flex-\${direction} gap-\${gap}\`}>{children}</div>
),
Card: ({ title, description, children }) => (
<div className="border rounded-lg p-4">
{title && <h3 className="font-semibold">{title}</h3>}
{description && <p className="text-muted-foreground text-sm">{description}</p>}
{children}
</div>
),
Text: ({ content, variant = 'body' }) => {
const styles = {
body: 'text-sm',
heading: 'text-lg font-semibold',
caption: 'text-xs text-muted-foreground',
code: 'font-mono text-sm bg-muted px-1 rounded',
};
return <p className={styles[variant]}>{content}</p>;
},
Button: ({ label, variant = 'primary', onClick }) => (
<button
className={\`px-4 py-2 rounded text-sm \${
variant === 'primary' ? 'bg-primary text-primary-foreground' :
variant === 'secondary' ? 'bg-secondary text-secondary-foreground' :
'border'
}\`}
onClick={onClick}
>
{label}
</button>
),
Metric: ({ label, value, change, format }) => {
const formatted = format === 'currency' ? \`$\${value.toLocaleString()}\` :
format === 'percent' ? \`\${value}%\` :
value.toLocaleString();
return (
<div className="p-4 border rounded">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{formatted}</p>
{change !== undefined && (
<p className={\`text-sm \${change >= 0 ? 'text-green-600' : 'text-red-600'}\`}>
{change >= 0 ? '+' : ''}{change}%
</p>
)}
</div>
);
},
Alert: ({ message, type = 'info' }) => {
const styles = {
info: 'bg-blue-50 text-blue-800 border-blue-200',
success: 'bg-green-50 text-green-800 border-green-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
error: 'bg-red-50 text-red-800 border-red-200',
};
return <div className={\`p-3 rounded border \${styles[type]}\`}>{message}</div>;
},
// Add more components...
};
// Render tool call result
export function renderToolCallUI(toolCall: { name: string; args: string }) {
if (toolCall.name !== 'render_ui') return null;
try {
const parsed = JSON.parse(toolCall.args);
const Component = components[parsed.component];
if (!Component) return null;
return <Component {...parsed.props} />;
} catch {
return null;
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useAGUI } from './use-agui';
import { renderToolCallUI } from './renderer';
export function AGUIChat() {
const { state, processEvent } = useAGUI();
// Connect to AG-UI event stream
async function startRun(prompt: string) {
const response = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\\n').filter(Boolean);
for (const line of lines) {
const event = JSON.parse(line);
processEvent(event);
}
}
}
return (
<div className="space-y-4">
{/* Messages */}
{state.messages.map(msg => (
<div key={msg.id} className={\`p-3 rounded \${
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
}\`}>
{msg.content}
</div>
))}
{/* Rendered UI from tool calls */}
{Array.from(state.toolCalls.values()).map((tc, i) => (
<div key={i}>{renderToolCallUI(tc)}</div>
))}
{/* Input */}
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input?.value) {
startRun(input.value);
input.value = '';
}
}}>
<input
type="text"
placeholder="Ask the agent..."
className="w-full px-4 py-2 border rounded"
disabled={state.isRunning}
/>
</form>
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/openapi" className="text-foreground hover:underline">
OpenAPI integration
</Link>{" "}
for rendering forms from API schemas.
</p>
</article>
);
}
@@ -19,13 +19,13 @@ export default function AiSdkPage() {
<h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
// Optionally include current UI state for context
const contextPrompt = currentTree
@@ -33,17 +33,12 @@ export async function POST(req: Request) {
: '';
const result = streamText({
model: 'anthropic/claude-opus-4.5',
model: 'anthropic/claude-haiku-4.5',
system: systemPrompt + contextPrompt,
prompt,
});
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
@@ -52,32 +47,33 @@ export async function POST(req: Request) {
</p>
<Code lang="tsx">{`'use client';
import { useUIStream } from '@json-render/react';
import { useUIStream, Renderer } from '@json-render/react';
function GenerativeUI() {
const { tree, isLoading, error, generate } = useUIStream({
endpoint: '/api/generate',
const { spec, isStreaming, error, send } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button
onClick={() => generate('Create a dashboard with metrics')}
disabled={isLoading}
onClick={() => send('Create a dashboard with metrics')}
disabled={isStreaming}
>
{isLoading ? 'Generating...' : 'Generate'}
{isStreaming ? 'Generating...' : 'Generate'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer tree={tree} registry={registry} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Prompt Engineering</h2>
<p className="text-sm text-muted-foreground mb-4">
The <code className="text-foreground">generateCatalogPrompt</code> function creates an optimized prompt that:
The <code className="text-foreground">catalog.prompt()</code> method
creates an optimized system prompt that:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Lists all available components and their props</li>
@@ -86,21 +82,30 @@ function GenerativeUI() {
<li>Includes examples for better generation</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Custom System Prompts</h2>
<Code lang="typescript">{`const basePrompt = generateCatalogPrompt(catalog);
const customPrompt = \`
\${basePrompt}
Additional instructions:
- Always use Card components for grouping related content
- Prefer horizontal layouts (Row) for metrics
- Use consistent spacing with padding="md"
\`;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Custom System Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Pass custom rules to tailor AI behavior:
</p>
<Code lang="typescript">{`const systemPrompt = catalog.prompt({
customRules: [
'Always use Card components for grouping related content',
'Prefer horizontal layouts (Row) for metrics',
'Use consistent spacing with padding="md"',
],
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/streaming" className="text-foreground hover:underline">progressive streaming</Link>.
Learn about{" "}
<Link
href="/docs/streaming"
className="text-foreground hover:underline"
>
progressive streaming
</Link>
.
</p>
</article>
);
@@ -0,0 +1,130 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/codegen API | json-render",
};
export default function CodegenApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
<p className="text-muted-foreground mb-8">
Utilities for generating code from UI trees.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
<p className="text-sm text-muted-foreground mb-4">
Walk the UI spec depth-first.
</p>
<Code lang="typescript">{`function traverseSpec(
spec: Spec,
visitor: SpecVisitor,
startKey?: string
): void
interface SpecVisitor {
(element: UIElement, depth: number, parent: UIElement | null): void;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all unique component types used in a spec.
</p>
<Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
// Example
const components = collectUsedComponents(spec);
// Set { 'Card', 'Metric', 'Chart' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all data paths referenced in props (valuePath, dataPath, bindPath,
etc.).
</p>
<Code lang="typescript">{`function collectDataPaths(spec: Spec): Set<string>
// Example
const paths = collectDataPaths(spec);
// Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all action names used in the spec.
</p>
<Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
// Example
const actions = collectActions(spec);
// Set { 'submit_form', 'refresh_data' }`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">serializePropValue</h3>
<p className="text-sm text-muted-foreground mb-4">
Serialize a single value to a code string.
</p>
<Code lang="typescript">{`function serializePropValue(
value: unknown,
options?: SerializeOptions
): { value: string; needsBraces: boolean }
// Examples
serializePropValue("hello")
// { value: '"hello"', needsBraces: false }
serializePropValue(42)
// { value: '42', needsBraces: true }
serializePropValue({ path: 'user/name' })
// { value: '{ path: "user/name" }', needsBraces: true }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
<p className="text-sm text-muted-foreground mb-4">
Serialize a props object to a JSX attributes string.
</p>
<Code lang="typescript">{`function serializeProps(
props: Record<string, unknown>,
options?: SerializeOptions
): string
// Example
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
// 'title="Dashboard" columns={3} disabled'`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">escapeString</h3>
<p className="text-sm text-muted-foreground mb-4">
Escape a string for use in code.
</p>
<Code lang="typescript">{`function escapeString(
str: string,
quotes?: 'single' | 'double'
): string`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">GeneratedFile</h3>
<Code lang="typescript">{`interface GeneratedFile {
/** File path relative to project root */
path: string;
/** File contents */
content: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
<Code lang="typescript">{`interface CodeGenerator {
/** Generate files from a UI spec */
generate(spec: Spec): GeneratedFile[];
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
<Code lang="typescript">{`interface SerializeOptions {
/** Quote style for strings */
quotes?: 'single' | 'double';
/** Indent for objects/arrays */
indent?: number;
}`}</Code>
</article>
);
}
+403
View File
@@ -0,0 +1,403 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/core API | json-render",
};
export default function CoreApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
<p className="text-muted-foreground mb-8">
Core types, schemas, and utilities.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a type-safe catalog definition with schema validation.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
function defineCatalog<T extends ZodType>(
s: T,
config: CatalogConfig
): Catalog
// Use the React schema for standard UI specs
const catalog = defineCatalog(schema, {
components: {...},
actions: {...},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
<Code lang="typescript">{`interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
functions?: Record<string, FunctionDefinition>;
}
interface ComponentDefinition {
props: ZodObject; // Use .nullable() for optional props
slots?: string[]; // Named slots (e.g., ["default"])
description?: string; // Help AI understand usage
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}
interface FunctionDefinition {
description?: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
<p className="text-sm text-muted-foreground mb-4">
The returned catalog provides methods for AI prompt generation,
validation, and schema export:
</p>
<Code lang="typescript">{`interface Catalog {
// Data
readonly data: CatalogConfig; // The catalog configuration
readonly componentNames: string[]; // List of component names
readonly actionNames: string[]; // List of action names
// AI Prompt Generation
prompt(options?: PromptOptions): string;
// Validation
validate(spec: unknown): SpecValidationResult;
zodSchema(): z.ZodType; // Get the Zod schema for specs
// Export
jsonSchema(): object; // Export as JSON Schema
}
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
}
interface SpecValidationResult<T> {
success: boolean;
data?: T; // Validated spec (if success)
error?: z.ZodError; // Validation errors (if failed)
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
<Code lang="typescript">{`// Generate AI system prompt
const systemPrompt = catalog.prompt({
customRules: ["Always use Card as root element"],
});
// Validate a spec from AI
const result = catalog.validate(aiOutput);
if (result.success) {
render(result.data);
} else {
console.error(result.error);
}
// Get Zod schema for custom validation
const schema = catalog.zodSchema();
const parsed = schema.safeParse(aiOutput);
// Export as JSON Schema (for structured outputs)
const jsonSchema = catalog.jsonSchema();`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses a flexible schema system that defines both the AI
output format (spec) and what catalogs must provide. Each renderer
package provides its own schema (e.g., @json-render/react exports{" "}
<code className="text-foreground">schema</code>).
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema for flat UI element trees. This is exported from
@json-render/react.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
// - Catalog shape: { components: {...}, actions: {...} }
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
description: "Container card",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom schemas for different output formats (e.g., page-based,
block-based).
</p>
<Code lang="typescript">{`import { defineSchema } from '@json-render/core';
const mySchema = defineSchema((s) => ({
// What the AI outputs (spec)
spec: s.object({
title: s.string(),
blocks: s.array(s.object({
type: s.ref("catalog.blocks"),
content: s.any(),
})),
}),
// What the catalog must provide
catalog: s.object({
blocks: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema builder provides these methods:
</p>
<Code lang="typescript">{`// Primitive types
s.string() // String value
s.number() // Number value
s.boolean() // Boolean value
s.any() // Any value
// Compound types
s.array(item) // Array of items
s.object({ ... }) // Object with shape
s.record(value) // Record/map with value type
// Catalog references (for type safety)
s.ref("catalog.components") // Reference to catalog key (becomes enum)
s.propsOf("catalog.components") // Props schema from catalog entry
// Catalog definitions
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
s.zod() // Placeholder for user-provided Zod schema
// Modifiers
s.optional() // Mark field as optional`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built Zod schemas for common json-render types:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
<Code lang="typescript">{`import {
DynamicValueSchema, // string | number | boolean | null | { path: string }
DynamicStringSchema, // string | { path: string }
DynamicNumberSchema, // number | { path: string }
DynamicBooleanSchema, // boolean | { path: string }
} from '@json-render/core';
// Dynamic values can be literals or data path references
type DynamicValue<T> = T | { path: string };
// Example: a prop that can be a literal or bound to data
const schema = z.object({
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Visibility &amp; Logic Schemas
</h3>
<Code lang="typescript">{`import {
VisibilityConditionSchema, // Full visibility condition
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
} from '@json-render/core';
// Use in component props that need conditional rendering
const schema = z.object({
visible: VisibilityConditionSchema.optional(),
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
<Code lang="typescript">{`import {
ActionSchema, // Full action definition
ActionConfirmSchema, // Confirmation dialog config
ActionOnSuccessSchema, // Success handler config
ActionOnErrorSchema, // Error handler config
} from '@json-render/core';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
<Code lang="typescript">{`import {
ValidationCheckSchema, // Single validation check
ValidationConfigSchema, // Full validation config with checks array
} from '@json-render/core';`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
createSpecStreamCompiler
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create a streaming compiler that incrementally builds a spec:
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
// Get final result
const spec = compiler.getResult();
// Reset for reuse
compiler.reset();`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
Compile an entire SpecStream string at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{}}
{"op":"set","path":"/root/type","value":"Card"}\`;
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
<Code lang="typescript">{`import {
parseSpecStreamLine,
applySpecStreamPatch,
} from '@json-render/core';
// Parse a single line
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
<Code lang="typescript">{`interface SpecStreamLine {
op: 'set' | 'add' | 'replace' | 'remove';
path: string;
value?: unknown;
}
interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T;
reset(): void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
<Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
// Get value by JSON Pointer path
const value = getByPath(data, '/user/name'); // "Alice"
// Set value by path (mutates object)
setByPath(data, '/user/email', 'alice@example.com');`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
<Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
// Resolve a dynamic value against data
const name = resolveDynamicValue("Hello", data); // "Hello"
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
<Code lang="typescript">{`import { findFormValue } from '@json-render/core';
// Find form values regardless of path format
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
const value = findFormValue("name", params, data);`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
<p className="text-sm text-muted-foreground mb-4">
Evaluates a visibility condition against data and auth state.
</p>
<Code lang="typescript">{`function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
): boolean
type VisibilityCondition =
| { path: string }
| { auth: 'signedIn' | 'signedOut' | string }
| { and: VisibilityCondition[] }
| { or: VisibilityCondition[] }
| { not: VisibilityCondition }
| { eq: [DynamicValue, DynamicValue] }
| { gt: [DynamicValue, DynamicValue] }
| { gte: [DynamicValue, DynamicValue] }
| { lt: [DynamicValue, DynamicValue] }
| { lte: [DynamicValue, DynamicValue] };`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
<Code lang="typescript">{`interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: string[]; // Keys of child elements
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
<Code lang="typescript">{`interface Spec {
root: string | null; // Key of root element
elements: Record<string, UIElement>;
}`}</Code>
<p className="text-sm text-muted-foreground mt-2 mb-4">
Elements are stored as a flat map with string keys. The tree structure
is built by following the{" "}
<code className="text-foreground">children</code> arrays.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
<Code lang="typescript">{`interface Action {
name: string;
params?: Record<string, unknown>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
<Code lang="typescript">{`interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
fn: string;
args?: Record<string, unknown>;
message: string;
}`}</Code>
</article>
);
}
@@ -43,33 +43,66 @@ interface AuthState {
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
tree={UITree}
registry={ComponentRegistry}
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
/>
type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
interface ComponentProps {
element: UIElement;
children?: React.ReactNode;
onAction: (name: string, params: object) => void;
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const {
tree, // UITree - current UI state
isLoading, // boolean - true while streaming
error, // Error | null
generate, // (prompt: string) => void
abort, // () => void
spec, // Spec - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string) => void
abort, // () => void
} = useUIStream({
endpoint: string,
api: string, // API endpoint URL
onChunk?: (chunk: string) => void, // Called for each chunk
onFinish?: (spec: Spec) => void, // Called when streaming completes
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
@@ -0,0 +1,240 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/remotion API | json-render",
};
export default function RemotionApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/remotion</h1>
<p className="text-muted-foreground mb-8">
Remotion video renderer. Turn JSON timeline specs into video
compositions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">schema</h2>
<p className="text-sm text-muted-foreground mb-4">
The timeline schema for video specs. Use with{" "}
<code className="text-foreground">defineCatalog</code> from core.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/remotion';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
The main composition component that renders timeline specs. Use with
Remotion&apos;s Player or in a Remotion project.
</p>
<Code lang="tsx">{`import { Player } from '@remotion/player';
import { Renderer } from '@json-render/remotion';
function VideoPlayer({ spec }) {
return (
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
controls
/>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Custom Components</h3>
<p className="text-sm text-muted-foreground mb-4">
Pass custom components to the Renderer:
</p>
<Code lang="tsx">{`import { Renderer, standardComponents } from '@json-render/remotion';
const customComponents = {
...standardComponents,
MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
};
<Player
component={Renderer}
inputProps={{ spec, components: customComponents }}
// ...
/>`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Standard Components</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built video components included in the package:
</p>
<Code lang="typescript">{`import {
TitleCard, // Full-screen title with subtitle
ImageSlide, // Full-screen image display
SplitScreen, // Two-column layout
QuoteCard, // Quote with attribution
StatCard, // Large statistic display
LowerThird, // Name/title overlay
TextOverlay, // Centered text overlay
TypingText, // Terminal typing animation
LogoBug, // Corner logo watermark
VideoClip, // Video playback
} from '@json-render/remotion';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TitleCard Props</h3>
<Code lang="typescript">{`{
title: string;
subtitle?: string;
backgroundColor?: string; // default: "#1a1a1a"
textColor?: string; // default: "#ffffff"
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TypingText Props</h3>
<Code lang="typescript">{`{
text: string;
charsPerSecond?: number; // default: 15
showCursor?: boolean; // default: true
cursorChar?: string; // default: "|"
fontFamily?: string; // default: "monospace"
fontSize?: number; // default: 48
textColor?: string; // default: "#00ff00"
backgroundColor?: string; // default: "#1e1e1e"
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Catalog Definitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built definitions for creating catalogs:
</p>
<Code lang="typescript">{`import {
standardComponentDefinitions, // All standard component definitions
standardTransitionDefinitions, // fade, slideLeft, slideRight, etc.
standardEffectDefinitions, // kenBurns, pulseGlow, colorShift
} from '@json-render/remotion';
// Use in your catalog
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
// Add custom components
},
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Hooks &amp; Utilities
</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useTransition</h3>
<p className="text-sm text-muted-foreground mb-4">
Calculate transition styles for a clip based on current frame:
</p>
<Code lang="typescript">{`import { useTransition } from '@json-render/remotion';
import { useCurrentFrame } from 'remotion';
function MyComponent({ clip }) {
const frame = useCurrentFrame();
const transition = useTransition(clip, frame);
return (
<div style={{
opacity: transition.opacity,
transform: transition.transform,
}}>
Content
</div>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ClipWrapper</h3>
<p className="text-sm text-muted-foreground mb-4">
Automatically apply transitions to clip content:
</p>
<Code lang="tsx">{`import { ClipWrapper } from '@json-render/remotion';
function MyClip({ clip }) {
return (
<ClipWrapper clip={clip}>
<div>My content with automatic transitions</div>
</ClipWrapper>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">TimelineSpec</h3>
<Code lang="typescript">{`interface TimelineSpec {
composition: {
id: string;
fps: number;
width: number;
height: number;
durationInFrames: number;
};
tracks: Track[];
clips: Clip[];
audio: {
tracks: AudioTrack[];
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Clip</h3>
<Code lang="typescript">{`interface Clip {
id: string;
trackId: string;
component: string;
props: Record<string, unknown>;
from: number;
durationInFrames: number;
transitionIn?: {
type: string;
durationInFrames: number;
};
transitionOut?: {
type: string;
durationInFrames: number;
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TransitionStyles</h3>
<Code lang="typescript">{`interface TransitionStyles {
opacity: number;
transform: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ComponentRegistry</h3>
<Code lang="typescript">{`type ClipComponent = React.ComponentType<{ clip: Clip }>;
type ComponentRegistry = Record<string, ClipComponent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Transitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Available transition types:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">fade</code> - Opacity fade in/out
</li>
<li>
<code className="text-foreground">slideLeft</code> - Slide from right
</li>
<li>
<code className="text-foreground">slideRight</code> - Slide from left
</li>
<li>
<code className="text-foreground">slideUp</code> - Slide from bottom
</li>
<li>
<code className="text-foreground">slideDown</code> - Slide from top
</li>
<li>
<code className="text-foreground">zoom</code> - Scale zoom in/out
</li>
<li>
<code className="text-foreground">wipe</code> - Horizontal wipe
</li>
</ul>
</article>
);
}
+126
View File
@@ -0,0 +1,126 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Catalog | json-render",
};
export default function CatalogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Catalog</h1>
<p className="text-muted-foreground mb-8">
The catalog defines what AI can generate. It&apos;s your guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
<p className="text-sm text-muted-foreground mb-4">
A catalog is a schema that defines:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Components</strong> — UI elements
AI can create (with props and optional slots)
</li>
<li>
<strong className="text-foreground">Actions</strong> — Operations AI
can trigger
</li>
<li>
<strong className="text-foreground">Functions</strong> — Custom
validation or transformation functions
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: {
// Define each component with its props schema
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
padding: z.enum(['sm', 'md', 'lg']).nullable(),
}),
slots: ["default"], // Can contain other components
description: "Container card for grouping content",
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // JSON Pointer to data
format: z.enum(['currency', 'percent', 'number']),
}),
description: "Display a single metric value",
},
},
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
description: 'Export data in various formats',
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the catalog has:
</p>
<Code lang="typescript">{`{
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
slots?: string[], // Named slots for children (e.g., ["default"])
description?: string, // Help AI understand when to use it
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Use{" "}
<code className="text-foreground">slots: [&quot;default&quot;]</code>{" "}
for components that can contain children. The slot name corresponds to
where child elements are rendered.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
Generating AI Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the <code className="text-foreground">catalog.prompt()</code> method
to generate a system prompt for AI:
</p>
<Code lang="typescript">{`// Generate a system prompt from your catalog
const systemPrompt = catalog.prompt();
// Or with custom rules for the AI
const customPrompt = catalog.prompt({
customRules: [
"Always use Card as the root element for forms",
"Group related inputs in a Stack with direction=vertical",
],
});
// Pass this to your AI model as the system prompt`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn how to{" "}
<Link href="/docs/registry" className="text-foreground hover:underline">
register components
</Link>{" "}
in your registry.
</p>
</article>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Changelog | json-render",
};
export default function ChangelogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Changelog</h1>
<p className="text-muted-foreground mb-8">
Notable changes and updates to json-render.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">v0.4.0</h2>
<p className="text-sm text-muted-foreground mb-6">February 2026</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: Custom Schema System
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom output formats with <code>defineSchema</code>. Each
renderer now defines its own schema, enabling completely different spec
formats for different use cases.
</p>
<Code lang="typescript">{`import { defineSchema } from "@json-render/core";
const mySchema = defineSchema((s) => ({
spec: s.object({
pages: s.array(s.object({
title: s.string(),
blocks: s.array(s.ref("catalog.blocks")),
})),
}),
catalog: s.object({
blocks: s.map({ props: s.zod(), description: s.string() }),
}),
}), {
promptTemplate: myPromptTemplate,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">New: Component Slots</h3>
<p className="text-sm text-muted-foreground mb-4">
Components can now define which slots they accept. Use{" "}
<code>[&quot;default&quot;]</code> for regular children, or named slots
like <code>[&quot;header&quot;, &quot;footer&quot;]</code> for more
complex layouts.
</p>
<Code lang="typescript">{`const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"], // accepts children
description: "A card container",
},
Layout: {
props: z.object({}),
slots: ["header", "content", "footer"], // named slots
description: "Page layout with header, content, footer",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: AI Prompt Generation
</h3>
<p className="text-sm text-muted-foreground mb-4">
Catalogs now generate AI system prompts automatically with{" "}
<code>catalog.prompt()</code>. The prompt includes all component
definitions, props schemas, and action descriptions - ensuring the AI
only generates valid specs.
</p>
<Code lang="typescript">{`import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: { /* ... */ },
});
// Generate system prompt for AI
const systemPrompt = catalog.prompt();
// Use with any AI SDK
const result = await streamText({
model: "claude-haiku-4.5",
system: systemPrompt,
prompt: userMessage,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: @json-render/remotion
</h3>
<p className="text-sm text-muted-foreground mb-4">
Generate AI-powered videos with Remotion. Define video catalogs, stream
timeline specs, and render with the Remotion Player.
</p>
<Code lang="tsx">{`import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
});
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Includes 10 standard video components (TitleCard, TypingText,
SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for
custom components.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">New: SpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches. The new compiler API makes it easy to
process streaming AI responses.
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Dashboard Example
</h3>
<p className="text-sm text-muted-foreground mb-4">
The dashboard example is now a full-featured accounting dashboard with:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Persistent SQLite database with Drizzle ORM</li>
<li>RESTful API for customers, invoices, expenses, accounts</li>
<li>Draggable widget reordering</li>
<li>AI-powered widget generation with streaming</li>
<li>Real data binding to database records</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Documentation
</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Interactive playground for testing specs</li>
<li>New guides: Custom Schema, Streaming, Code Export</li>
<li>Full API reference for all packages</li>
<li>Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">Breaking Changes</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code>UITree</code> type renamed to <code>Spec</code>
</li>
<li>
Schema is now imported from renderer packages (
<code>@json-render/react</code>) not core
</li>
<li>
<code>defineCatalog</code> now requires a schema as first argument
</li>
</ul>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.3.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Internal release with codegen foundations.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
Added <code>@json-render/codegen</code> package (spec traversal and
JSX serialization)
</li>
<li>Configurable AI model via environment variables</li>
<li>Documentation improvements and bug fixes</li>
</ul>
<p className="text-sm text-muted-foreground italic">
Note: Only @json-render/core was published to npm for this release.
</p>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.2.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Initial public release.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Core catalog and spec types</li>
<li>React renderer with contexts for data, actions, visibility</li>
<li>AI prompt generation from catalogs</li>
<li>Basic streaming support</li>
<li>Dashboard example application</li>
</ul>
</article>
);
}
@@ -0,0 +1,170 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Code Export | json-render",
};
export default function CodeExportPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Code Export</h1>
<p className="text-muted-foreground mb-8">
Export generated UI as standalone code for your framework.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
While json-render is designed for dynamic rendering, you can export
generated UI as static code. The code generation is intentionally
project-specific so you have full control over:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
<li>Component templates (standalone, no json-render dependencies)</li>
<li>Package.json and project structure</li>
<li>Framework-specific patterns (Next.js, Remix, etc.)</li>
<li>How data is passed to components</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
<p className="text-sm text-muted-foreground mb-4">
Code export is split into two parts:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
1. @json-render/codegen (utilities)
</h3>
<p className="text-sm text-muted-foreground mb-4">
Framework-agnostic utilities for building code generators:
</p>
<Code lang="typescript">{`import {
traverseSpec, // Walk the UI spec
collectUsedComponents, // Get all component types used
collectDataPaths, // Get all data binding paths
collectActions, // Get all action names
serializeProps, // Convert props to JSX string
} from '@json-render/codegen';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
2. Your Project (generator)
</h3>
<p className="text-sm text-muted-foreground mb-4">
Custom code generator specific to your project and framework:
</p>
<Code lang="typescript">{`// lib/codegen/generator.ts
import { collectUsedComponents, serializeProps } from '@json-render/codegen';
export function generateNextJSProject(spec: Spec): GeneratedFile[] {
const components = collectUsedComponents(spec);
return [
{ path: 'package.json', content: '...' },
{ path: 'app/page.tsx', content: '...' },
// ... component files
];
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Example: Next.js Export
</h2>
<p className="text-sm text-muted-foreground mb-4">
See the dashboard example for a complete implementation that exports:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li>
<code className="text-foreground">package.json</code> - Dependencies
and scripts
</li>
<li>
<code className="text-foreground">tsconfig.json</code> - TypeScript
config
</li>
<li>
<code className="text-foreground">next.config.js</code> - Next.js
config
</li>
<li>
<code className="text-foreground">app/layout.tsx</code> - Root layout
</li>
<li>
<code className="text-foreground">app/globals.css</code> - Global
styles
</li>
<li>
<code className="text-foreground">app/page.tsx</code> - Generated page
with data
</li>
<li>
<code className="text-foreground">components/ui/*.tsx</code> -
Standalone components
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Standalone Components
</h2>
<p className="text-sm text-muted-foreground mb-4">
The exported components are standalone with no json-render dependencies.
They receive data as props instead of using hooks:
</p>
<Code lang="tsx">{`// Generated component (standalone)
interface MetricProps {
label: string;
valuePath: string;
data?: Record<string, unknown>;
}
export function Metric({ label, valuePath, data }: MetricProps) {
const value = data ? getByPath(data, valuePath) : undefined;
return (
<div>
<span>{label}</span>
<span>{formatValue(value)}</span>
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
<Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
traverseSpec(spec, (element, depth, parent) => {
console.log(' '.repeat(depth * 2) + element.type);
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
const components = collectUsedComponents(spec);
// Set { 'Card', 'Metric', 'Chart', 'Table' }
// Generate only the needed component files
for (const component of components) {
files.push({
path: \`components/ui/\${component.toLowerCase()}.tsx\`,
content: componentTemplates[component],
});
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
<Code lang="typescript">{`import { serializeProps } from '@json-render/codegen';
const propsStr = serializeProps({
title: 'Dashboard',
columns: 3,
disabled: true,
});
// 'title="Dashboard" columns={3} disabled'`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Try It</h2>
<p className="text-sm text-muted-foreground mb-4">
Run the dashboard example and click &quot;Export Project&quot; to see
code generation in action:
</p>
<Code lang="bash">{`cd examples/dashboard
pnpm dev
# Open http://localhost:3001
# Generate a widget, then click "Export Project"`}</Code>
</article>
);
}
@@ -0,0 +1,335 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Custom Schema & Renderer | json-render",
};
export default function CustomSchemaPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Custom Schema & Renderer</h1>
<p className="text-muted-foreground mb-8">
Build your own schema and renderer with{" "}
<code className="text-foreground">@json-render/core</code>.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. While{" "}
<code className="text-foreground">@json-render/react</code> provides a
ready-to-use schema and renderer, you can create your own to match any
JSON structure - whether it&apos;s a domain-specific format, an existing
protocol, or something entirely custom.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
1. Define Your Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Start by defining the JSON structure your system will use. Here&apos;s
an example of a simple dashboard schema:
</p>
<Code lang="json">{`{
"layout": "grid",
"columns": 2,
"widgets": [
{
"type": "metric",
"title": "Revenue",
"value": "$12,345",
"trend": "up"
},
{
"type": "chart",
"title": "Sales",
"chartType": "line",
"dataKey": "salesData"
},
{
"type": "table",
"title": "Recent Orders",
"columns": ["id", "customer", "amount"],
"dataKey": "orders"
}
]
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create the Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog that describes your components and validates props:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const dashboardCatalog = createCatalog({
components: {
metric: {
description: 'Displays a single metric value',
props: z.object({
title: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
change: z.string().optional(),
}),
},
chart: {
description: 'Renders a chart visualization',
props: z.object({
title: z.string(),
chartType: z.enum(['line', 'bar', 'pie', 'area']),
dataKey: z.string(),
height: z.number().optional(),
}),
},
table: {
description: 'Displays tabular data',
props: z.object({
title: z.string(),
columns: z.array(z.string()),
dataKey: z.string(),
pageSize: z.number().optional(),
}),
},
text: {
description: 'Displays text content',
props: z.object({
content: z.string(),
variant: z.enum(['heading', 'body', 'caption']).optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Define the Root Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a schema for the overall document structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
const WidgetSchema = z.object({
type: z.string(),
title: z.string().optional(),
// Additional props validated by catalog
}).passthrough();
export const DashboardSchema = z.object({
layout: z.enum(['grid', 'stack', 'tabs']),
columns: z.number().optional(),
widgets: z.array(WidgetSchema),
});
export type Dashboard = z.infer<typeof DashboardSchema>;
export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
4. Build the Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that maps your schema to React components:
</p>
<Code lang="tsx">{`import React from 'react';
import { dashboardCatalog } from './catalog';
import type { Dashboard, Widget } from './schema';
// Widget component registry
const widgetComponents: Record<string, React.FC<any>> = {
metric: ({ title, value, trend, change }) => (
<div className="p-4 rounded-lg border">
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{trend && (
<p className={\`text-sm \${trend === 'up' ? 'text-green-500' : 'text-red-500'}\`}>
{trend === 'up' ? '+' : '-'}{change}
</p>
)}
</div>
),
chart: ({ title, chartType, data }) => (
<div className="p-4 rounded-lg border">
<p className="font-medium mb-2">{title}</p>
<div className="h-48 bg-muted rounded flex items-center justify-center">
{/* Your chart library here */}
<span className="text-muted-foreground">{chartType} chart</span>
</div>
</div>
),
table: ({ title, columns, data }) => (
<div className="p-4 rounded-lg border">
<p className="font-medium mb-2">{title}</p>
<table className="w-full text-sm">
<thead>
<tr>
{columns.map((col: string) => (
<th key={col} className="text-left p-2 border-b">{col}</th>
))}
</tr>
</thead>
<tbody>
{data?.map((row: any, i: number) => (
<tr key={i}>
{columns.map((col: string) => (
<td key={col} className="p-2 border-b">{row[col]}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
),
text: ({ content, variant = 'body' }) => {
const className = {
heading: 'text-xl font-bold',
body: 'text-base',
caption: 'text-sm text-muted-foreground',
}[variant];
return <p className={className}>{content}</p>;
},
};
// Main renderer
export function DashboardRenderer({
spec,
data = {},
}: {
spec: Dashboard;
data?: Record<string, any>;
}) {
const layoutClass = {
grid: \`grid gap-4 \${spec.columns ? \`grid-cols-\${spec.columns}\` : 'grid-cols-2'}\`,
stack: 'flex flex-col gap-4',
tabs: 'space-y-4',
}[spec.layout];
return (
<div className={layoutClass}>
{spec.widgets.map((widget, index) => {
const Component = widgetComponents[widget.type];
if (!Component) {
console.warn(\`Unknown widget type: \${widget.type}\`);
return null;
}
// Resolve data references
const widgetData = widget.dataKey ? data[widget.dataKey] : undefined;
return (
<Component
key={index}
{...widget}
data={widgetData}
/>
);
})}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
5. Generate LLM Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the catalog to generate system prompts for AI:
</p>
<Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
customRules: [
'Use metric widgets for single KPI values',
'Use chart widgets for time-series data',
'Use table widgets for lists of records',
'Limit dashboards to 6 widgets maximum',
],
});
// Use with any LLM
const response = await generateText({
model: 'gpt-4',
system: systemPrompt,
prompt: 'Create a sales dashboard with revenue, orders, and a chart',
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
<p className="text-sm text-muted-foreground mb-4">
Validate incoming specs against your schema:
</p>
<Code lang="typescript">{`import { validate } from '@json-render/core';
function validateDashboard(spec: unknown) {
// Validate root structure
const rootResult = DashboardSchema.safeParse(spec);
if (!rootResult.success) {
return { valid: false, errors: rootResult.error.errors };
}
// Validate each widget against catalog
const errors: string[] = [];
for (const widget of rootResult.data.widgets) {
const result = validate(
{ type: widget.type, props: widget },
dashboardCatalog
);
if (!result.valid) {
errors.push(...result.errors.map(e => \`\${widget.type}: \${e}\`));
}
}
return { valid: errors.length === 0, errors };
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useState } from 'react';
import { DashboardRenderer } from './renderer';
import type { Dashboard } from './schema';
const initialSpec: Dashboard = {
layout: 'grid',
columns: 2,
widgets: [
{ type: 'metric', title: 'Revenue', value: '$12,345', trend: 'up' },
{ type: 'metric', title: 'Orders', value: '156', trend: 'up' },
{ type: 'chart', title: 'Sales Trend', chartType: 'line', dataKey: 'sales' },
{ type: 'table', title: 'Recent Orders', columns: ['id', 'customer', 'amount'], dataKey: 'orders' },
],
};
const data = {
sales: [/* chart data */],
orders: [
{ id: '001', customer: 'Acme Inc', amount: '$500' },
{ id: '002', customer: 'Globex', amount: '$750' },
],
};
export function MyDashboard() {
const [spec, setSpec] = useState(initialSpec);
return <DashboardRenderer spec={spec} data={data} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
See how to integrate with{" "}
<Link href="/docs/a2ui" className="text-foreground hover:underline">
A2UI
</Link>{" "}
or{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards
</Link>{" "}
protocols.
</p>
</article>
);
}
@@ -55,7 +55,8 @@ function App() {
<h2 className="text-xl font-semibold mt-12 mb-4">Reading Data</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">useDataValue</code> for read-only access:
Use <code className="text-foreground">useDataValue</code> for read-only
access:
</p>
<Code lang="tsx">{`import { useDataValue } from '@json-render/react';
@@ -66,7 +67,8 @@ function UserGreeting() {
<h2 className="text-xl font-semibold mt-12 mb-4">Two-Way Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">useDataBinding</code> for read-write access:
Use <code className="text-foreground">useDataBinding</code> for
read-write access:
</p>
<Code lang="tsx">{`import { useDataBinding } from '@json-render/react';
@@ -82,7 +84,9 @@ function EmailInput() {
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Data Context</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using the Data Context
</h2>
<p className="text-sm text-muted-foreground mb-4">
Access the full data context for advanced use cases:
</p>
@@ -118,7 +122,11 @@ function DataDebugger() {
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/actions" className="text-foreground hover:underline">actions</Link> for user interactions.
Learn about{" "}
<Link href="/docs/actions" className="text-foreground hover:underline">
actions
</Link>{" "}
for user interactions.
</p>
</article>
);
@@ -0,0 +1,43 @@
import { PackageInstall } from "@/components/package-install";
export const metadata = {
title: "Installation | json-render",
};
export default function InstallationPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Installation</h1>
<p className="text-muted-foreground mb-8">
Install the core package plus your renderer of choice.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">For React UI</h2>
<PackageInstall packages="@json-render/core @json-render/react" />
<h2 className="text-xl font-semibold mt-12 mb-4">For Remotion Video</h2>
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
<h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render requires the following peer dependencies:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">react</code> ^19.0.0
</li>
<li>
<code className="text-foreground">zod</code> ^4.0.0
</li>
</ul>
<PackageInstall packages="react zod" />
<h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
<p className="text-sm text-muted-foreground mb-4">
To use json-render with AI models, you&apos;ll also need the Vercel AI
SDK:
</p>
<PackageInstall packages="ai" />
</article>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar";
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<DocsMobileNav />
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
{/* Sidebar */}
<aside className="w-48 shrink-0 hidden lg:block sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
<DocsSidebar />
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
</div>
</>
);
}
+718
View File
@@ -0,0 +1,718 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "OpenAPI Integration | json-render",
};
export default function OpenAPIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to generate dynamic forms and UIs from{" "}
<a
href="https://swagger.io/specification/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
OpenAPI/Swagger
</a>{" "}
schemas.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support OpenAPI schemas. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
<p className="text-sm text-muted-foreground mb-4">
OpenAPI specifications describe your API{"'"}s endpoints, request
bodies, and response schemas. By converting OpenAPI schemas to
json-render specs, you can:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Automatically generate forms for API endpoints</li>
<li>Display API responses with type-aware rendering</li>
<li>Keep your UI in sync with your API schema</li>
<li>Let AI generate UIs that match your API contracts</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Example OpenAPI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
A typical OpenAPI schema for a request body:
</p>
<Code lang="json">{`{
"openapi": "3.0.0",
"paths": {
"/users": {
"post": {
"summary": "Create a new user",
"operationId": "createUser",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"required": ["email", "name"],
"properties": {
"name": {
"type": "string",
"description": "User's full name",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
},
"age": {
"type": "integer",
"minimum": 0,
"maximum": 150,
"description": "User's age"
},
"role": {
"type": "string",
"enum": ["admin", "user", "guest"],
"default": "user",
"description": "User's role"
},
"preferences": {
"type": "object",
"properties": {
"newsletter": {
"type": "boolean",
"default": false
},
"theme": {
"type": "string",
"enum": ["light", "dark", "system"]
}
}
}
}
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define an OpenAPI-to-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create components that map to OpenAPI data types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const openapiCatalog = createCatalog({
components: {
// Form container
Form: {
description: 'API form container',
props: z.object({
operationId: z.string(),
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Field components mapped to OpenAPI types
StringField: {
description: 'String input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
placeholder: z.string().optional(),
defaultValue: z.string().optional(),
}),
},
NumberField: {
description: 'Number input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
type: z.enum(['integer', 'number']).optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
exclusiveMinimum: z.number().optional(),
exclusiveMaximum: z.number().optional(),
multipleOf: z.number().optional(),
defaultValue: z.number().optional(),
}),
},
BooleanField: {
description: 'Boolean toggle field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
defaultValue: z.boolean().optional(),
}),
},
EnumField: {
description: 'Enum selection field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string().optional(),
})),
defaultValue: z.string().optional(),
}),
},
ArrayField: {
description: 'Array of items',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
}),
},
ObjectField: {
description: 'Nested object group',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
collapsible: z.boolean().optional(),
}),
},
// Response display components
ResponseDisplay: {
description: 'Displays API response',
props: z.object({
status: z.number(),
statusText: z.string().optional(),
}),
},
SchemaTable: {
description: 'Displays data matching a schema',
props: z.object({
schema: z.string(),
data: z.array(z.record(z.unknown())),
}),
},
},
actions: {
submit: {
description: 'Submit form to API endpoint',
params: z.object({
operationId: z.string(),
}),
},
reset: {
description: 'Reset form to defaults',
params: z.object({}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Convert OpenAPI Schema to Spec
</h2>
<p className="text-sm text-muted-foreground mb-4">
Transform OpenAPI schemas into json-render specs:
</p>
<Code lang="typescript">{`interface OpenAPISchema {
type?: string;
format?: string;
enum?: string[];
properties?: Record<string, OpenAPISchema>;
items?: OpenAPISchema;
required?: string[];
description?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
default?: unknown;
}
interface SpecElement {
key: string;
type: string;
props: Record<string, unknown>;
children: string[];
parentKey: string;
}
function schemaToSpec(
schema: OpenAPISchema,
name: string,
required: string[] = [],
parentKey: string = '',
elements: Map<string, SpecElement> = new Map(),
): string {
const key = parentKey ? \`\${parentKey}-\${name}\` : name;
const isRequired = required.includes(name);
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
if (schema.enum) {
elements.set(key, {
key,
type: 'EnumField',
props: {
name,
label,
description: schema.description,
required: isRequired,
options: schema.enum.map(v => ({ value: v, label: v })),
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'string') {
elements.set(key, {
key,
type: 'StringField',
props: {
name,
label,
description: schema.description,
required: isRequired,
format: schema.format || 'text',
minLength: schema.minLength,
maxLength: schema.maxLength,
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'integer' || schema.type === 'number') {
elements.set(key, {
key,
type: 'NumberField',
props: {
name,
label,
description: schema.description,
required: isRequired,
type: schema.type,
minimum: schema.minimum,
maximum: schema.maximum,
defaultValue: schema.default as number,
},
children: [],
parentKey,
});
} else if (schema.type === 'boolean') {
elements.set(key, {
key,
type: 'BooleanField',
props: {
name,
label,
description: schema.description,
defaultValue: schema.default as boolean,
},
children: [],
parentKey,
});
} else if (schema.type === 'array' && schema.items) {
const childKeys: string[] = [];
const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
childKeys.push(itemKey);
elements.set(key, {
key,
type: 'ArrayField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
} else if (schema.type === 'object' && schema.properties) {
const childKeys: string[] = [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
propName,
schema.required || [],
key,
elements,
);
childKeys.push(childKey);
}
elements.set(key, {
key,
type: 'ObjectField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
}
return key;
}
// Convert full OpenAPI operation to spec
export function operationToSpec(
operationId: string,
method: string,
path: string,
schema: OpenAPISchema,
title?: string,
description?: string,
) {
const elements = new Map<string, SpecElement>();
const rootKey = 'form';
const childKeys: string[] = [];
if (schema.properties) {
for (const [name, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
name,
schema.required || [],
rootKey,
elements,
);
childKeys.push(childKey);
}
}
elements.set(rootKey, {
key: rootKey,
type: 'Form',
props: {
operationId,
endpoint: path,
method: method.toUpperCase(),
title,
description,
},
children: childKeys,
parentKey: '',
});
return {
root: rootKey,
elements: Object.fromEntries(elements),
};
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an OpenAPI Form Renderer
</h2>
<Code lang="tsx">{`'use client';
import React, { useState } from 'react';
interface FieldProps {
name: string;
value: unknown;
onChange: (name: string, value: unknown) => void;
}
const fields: Record<string, React.FC<any>> = {
StringField: ({ name, label, description, required, format, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
/>
</div>
),
NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
value={(value as number) ?? ''}
min={minimum}
max={maximum}
onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
required={required}
/>
</div>
),
BooleanField: ({ name, label, description, value, onChange }) => (
<div className="flex items-start gap-2">
<input
type="checkbox"
id={name}
checked={Boolean(value)}
onChange={(e) => onChange(name, e.target.checked)}
className="mt-1"
/>
<div>
<label htmlFor={name} className="text-sm font-medium">{label}</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
</div>
),
EnumField: ({ name, label, description, required, options, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<select
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
>
<option value="">Select...</option>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label || opt.value}
</option>
))}
</select>
</div>
),
ObjectField: ({ name, label, description, children }) => (
<fieldset className="border rounded p-4 space-y-4">
<legend className="text-sm font-medium px-2">{label}</legend>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{children}
</fieldset>
),
Form: ({ title, description, endpoint, method, children, onSubmit }) => (
<form
className="space-y-4 max-w-md"
onSubmit={(e) => {
e.preventDefault();
onSubmit?.();
}}
>
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{children}
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
>
{method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
</button>
</form>
),
};
interface OpenAPIFormProps {
spec: {
root: string;
elements: Record<string, any>;
};
onSubmit: (data: Record<string, unknown>) => void;
}
export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>({});
const handleChange = (name: string, value: unknown) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
function renderElement(key: string): React.ReactNode {
const element = spec.elements[key];
if (!element) return null;
const Field = fields[element.type];
if (!Field) return null;
const children = element.children?.map(renderElement);
return (
<Field
key={key}
{...element.props}
value={formData[element.props.name]}
onChange={handleChange}
onSubmit={() => onSubmit(formData)}
>
{children}
</Field>
);
}
return <>{renderElement(spec.root)}</>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { OpenAPIForm } from './openapi-form';
import { operationToSpec } from './openapi-to-spec';
// Your OpenAPI schema (typically loaded from your API)
const createUserSchema = {
type: 'object',
required: ['email', 'name'],
properties: {
name: { type: 'string', description: "User's full name" },
email: { type: 'string', format: 'email', description: "User's email" },
age: { type: 'integer', minimum: 0, maximum: 150 },
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
},
};
// Convert to spec
const spec = operationToSpec(
'createUser',
'POST',
'/api/users',
createUserSchema,
'Create User',
'Add a new user to the system',
);
export function CreateUserForm() {
const handleSubmit = async (data: Record<string, unknown>) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
console.log('User created!');
}
};
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Auto-generating from OpenAPI Document
</h2>
<p className="text-sm text-muted-foreground mb-4">
Load and parse an OpenAPI document to generate forms for all operations:
</p>
<Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
import { operationToSpec } from './openapi-to-spec';
interface OpenAPIDocument {
paths: Record<string, Record<string, {
operationId?: string;
summary?: string;
description?: string;
requestBody?: {
content?: {
'application/json'?: {
schema?: any;
};
};
};
}>>;
components?: {
schemas?: Record<string, any>;
};
}
export async function loadOpenAPISpecs(specUrl: string) {
const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
const specs: Record<string, any> = {};
for (const [path, methods] of Object.entries(api.paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
const schema = operation.requestBody.content['application/json'].schema;
const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
specs[operationId] = operationToSpec(
operationId,
method,
path,
schema,
operation.summary,
operation.description,
);
}
}
return specs;
}
// Usage
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
// specs.createUser, specs.updateUser, etc.`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/streaming"
className="text-foreground hover:underline"
>
streaming
</Link>{" "}
for progressive UI rendering.
</p>
</article>
);
}
@@ -7,15 +7,17 @@ export default function DocsPage() {
<article>
<h1 className="text-3xl font-bold mb-4">Introduction</h1>
<p className="text-muted-foreground mb-8">
Predictable. Guardrailed. Fast. Let users generate dashboards, widgets, apps, and data visualizations from prompts.
Predictable. Guardrailed. Fast. Let users generate dashboards, widgets,
apps, and data visualizations from prompts.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is json-render?</h2>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
json-render lets end users generate UI from natural language prompts — safely constrained to
components you define. You set the guardrails: what components exist, what props they take,
what actions are available. AI generates JSON that matches your schema, and your components
render it natively.
json-render lets end users generate UI from natural language prompts —
safely constrained to components you define. You set the guardrails:
what components exist, what props they take, what actions are available.
AI generates JSON that matches your schema, and your components render
it natively.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
@@ -23,29 +25,42 @@ export default function DocsPage() {
<div>
<h3 className="font-medium mb-1">Guardrailed</h3>
<p className="text-sm text-muted-foreground">
AI can only use components in your catalog. No arbitrary code generation.
AI can only use components in your catalog. No arbitrary code
generation.
</p>
</div>
<div>
<h3 className="font-medium mb-1">Predictable</h3>
<p className="text-sm text-muted-foreground">
JSON output matches your schema, every time. Actions are declared by name, you control what they do.
JSON output matches your schema, every time. Actions are declared by
name, you control what they do.
</p>
</div>
<div>
<h3 className="font-medium mb-1">Fast</h3>
<p className="text-sm text-muted-foreground">
Stream and render progressively as the model responds. No waiting for completion.
Stream and render progressively as the model responds. No waiting
for completion.
</p>
</div>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">How it works</h2>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>Define the guardrails — what components, actions, and data bindings AI can use</li>
<li>Users prompt — end users describe what they want in natural language</li>
<li>AI generates JSON — output is always predictable, constrained to your catalog</li>
<li>Render fast — stream and render progressively as the model responds</li>
<li>
Define the guardrails — what components, actions, and data bindings AI
can use
</li>
<li>
Users prompt — end users describe what they want in natural language
</li>
<li>
AI generates JSON — output is always predictable, constrained to your
catalog
</li>
<li>
Render fast — stream and render progressively as the model responds
</li>
</ol>
</article>
);
@@ -0,0 +1,220 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Quick Start | json-render",
};
export default function QuickStartPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Quick Start</h1>
<p className="text-muted-foreground mb-8">
Get up and running with json-render in 5 minutes.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
1. Define your catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog that defines what components AI can use:
</p>
<Code lang="typescript">{`// lib/catalog.ts
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Container card with optional title",
},
Button: {
props: z.object({
label: z.string(),
action: z.string().nullable(),
}),
description: "Clickable button that triggers an action",
},
Text: {
props: z.object({
content: z.string(),
}),
description: "Text paragraph",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
navigate: {
params: z.object({ url: z.string() }),
description: "Navigate to a URL",
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Create an API route
</h2>
<p className="text-sm text-muted-foreground mb-4">
Set up a streaming API route for AI generation:
</p>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: systemPrompt,
prompt,
});
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { spec, isStreaming, send } = useUIStream({
api: '/api/generate',
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
send(formData.get('prompt') as string);
};
return (
<DataProvider initialData={{}}>
<VisibilityProvider>
<ActionProvider handlers={{
submit: (params) => console.log('Submit:', params),
navigate: (params) => console.log('Navigate:', params),
}}>
<form onSubmit={handleSubmit}>
<input
name="prompt"
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isStreaming}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
<li>
Learn about{" "}
<Link
href="/docs/catalog"
className="text-foreground hover:underline"
>
catalogs
</Link>{" "}
in depth
</li>
<li>
Explore{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values
</li>
<li>
Add{" "}
<Link
href="/docs/actions"
className="text-foreground hover:underline"
>
actions
</Link>{" "}
for interactivity
</li>
<li>
Implement{" "}
<Link
href="/docs/visibility"
className="text-foreground hover:underline"
>
conditional visibility
</Link>
</li>
</ul>
</article>
);
}
+248
View File
@@ -0,0 +1,248 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Registry | json-render",
};
export default function RegistryPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Registry</h1>
<p className="text-muted-foreground mb-8">
Register React components and action handlers to bring your catalog to
life.
</p>
{/* defineRegistry */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code>defineRegistry</code> to create a type-safe registry from your
catalog. Pass your components, actions, or both in a single call:
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
actions: {
submit_form: async (params, setData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(params),
});
const result = await res.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
The returned object contains:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>
<code>registry</code> - component registry for{" "}
<code>{"<Renderer />"}</code>
</li>
<li>
<code>handlers</code> - factory for ActionProvider-compatible handlers
</li>
<li>
<code>executeAction</code> - imperative action dispatch (for use
outside the React tree)
</li>
</ul>
{/* Component Props */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the registry receives a <code>ComponentContext</code>{" "}
object:
</p>
<Code lang="typescript">{`interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: ActionTrigger) => void; // Dispatch an action
loading?: boolean; // Whether the renderer is in a loading state
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Props are automatically inferred from your catalog, so{" "}
<code>props.title</code> is typed as <code>string</code> if your catalog
defines it that way.
</p>
{/* Action Handlers */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
name. Your application provides the implementation. This is a core
guardrail.
</p>
<h3 className="text-lg font-medium mt-8 mb-3">Defining Actions</h3>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4">
Action handlers receive <code>(params, setData, data)</code> and are
defined inside <code>defineRegistry</code>:
</p>
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
actions: {
submit_form: async (params, setData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
const result = await response.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
},
});`}</Code>
{/* Using Data Binding */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks inside your registry components to read and write data:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
import { getByPath } from '@json-render/core';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useData();
const value = getByPath(data, props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
},
TextField: ({ props }) => {
const { data, set } = useData();
const value = getByPath(data, props.valuePath) as string;
return (
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
placeholder={props.placeholder}
/>
);
},`}</Code>
{/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
Wire everything together with providers and the{" "}
<code>{"<Renderer />"}</code> component:
</p>
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
import {
Renderer,
DataProvider,
VisibilityProvider,
ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setData }) {
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
const actionHandlers = useMemo(
() => handlers(() => setDataRef.current, () => dataRef.current),
[],
);
return (
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values.
</p>
</article>
);
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Schemas | json-render",
};
export default function SchemasPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Schemas</h1>
<p className="text-muted-foreground mb-8">
Schemas define the structure and validation rules for your UI specs.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
<p className="text-sm text-muted-foreground mb-4">
A schema defines the JSON structure that describes your UI. It includes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Element structure</strong> — How
components are nested and referenced
</li>
<li>
<strong className="text-foreground">Property types</strong> — What
props each component accepts
</li>
<li>
<strong className="text-foreground">Data binding syntax</strong> — How
to reference dynamic data
</li>
<li>
<strong className="text-foreground">Action format</strong> — How user
interactions are defined
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Schema-Agnostic by Design
</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render can work with any JSON schema.{" "}
<code className="text-foreground">@json-render/core</code> provides the
primitives to define catalogs and renderers for any format:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">@json-render/react</strong> — The
built-in flat element tree schema
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/a2ui" className="hover:underline">
A2UI
</Link>
</strong>{" "}
— Google{"'"}s Agent-to-User Interaction protocol
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/adaptive-cards" className="hover:underline">
Adaptive Cards
</Link>
</strong>{" "}
— Microsoft{"'"}s platform-agnostic UI format
</li>
<li>
<strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
Agent User Interaction Protocol
</li>
<li>
<strong className="text-foreground">OpenAPI/Swagger</strong> — API
documentation schemas for dynamic forms
</li>
<li>
<strong className="text-foreground">Custom schemas</strong> — Design
your own format tailored to your domain
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
See the{" "}
<Link
href="/docs/custom-schema"
className="text-foreground hover:underline"
>
Custom Schema guide
</Link>{" "}
to learn how to implement support for any schema.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/react</code> uses a flat
element tree schema with a root key and elements map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["text-1", "button-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Welcome, $data.user.name" },
"children": [],
"parentKey": "card-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Click me" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
In the built-in schema, each element in the elements map has this
structure:
</p>
<Code lang="typescript">{`interface Element {
key: string; // Unique identifier
type: string; // Component type from catalog
props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys
parentKey: string; // Parent element key (empty for root)
visible?: VisibilityRule; // Conditional display
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
<p className="text-sm text-muted-foreground mb-4">
Reference dynamic data using the{" "}
<code className="text-foreground">$data</code> prefix in props:
</p>
<Code lang="json">{`{
"key": "greeting",
"type": "Text",
"props": {
"content": "$data.user.name",
"count": "$data.items.length"
},
"children": [],
"parentKey": "card-1"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
<p className="text-sm text-muted-foreground mb-4">
Actions are defined in the catalog and referenced from components. The
renderer handles action execution:
</p>
<Code lang="typescript">{`// In your catalog
actions: {
navigate: {
params: z.object({ url: z.string() }),
description: 'Navigate to a URL',
},
apiCall: {
params: z.object({
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
}),
description: 'Make an API request',
},
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. You can define any JSON structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Define your own element schema
const MyElementSchema = z.object({
component: z.string(),
settings: z.record(z.unknown()),
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
});
// Define your own data binding format
const BoundValue = z.object({
literal: z.string().optional(),
path: z.string().optional(), // e.g., "/users/0/name"
});
// Define your own action format
const ActionSchema = z.object({
name: z.string(),
context: z.record(z.unknown()).optional(),
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
<p className="text-sm text-muted-foreground mb-4">
The schema and catalog work together but serve different purposes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Schema</strong> — Defines the JSON
structure (how elements are organized)
</li>
<li>
<strong className="text-foreground">Catalog</strong> — Defines
available components and their props (what can be used)
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
The schema is the grammar; the catalog is the vocabulary.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/specs" className="text-foreground hover:underline">
specs
</Link>{" "}
— the actual JSON documents that describe your UI.
</p>
</article>
);
}
+369
View File
@@ -0,0 +1,369 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Specs | json-render",
};
export default function SpecsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Specs</h1>
<p className="text-muted-foreground mb-8">
A spec is a JSON document that describes your UI.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
<p className="text-sm text-muted-foreground mb-4">
A spec (specification) is the actual JSON that describes a UI. It
conforms to a{" "}
<Link href="/docs/schemas" className="text-foreground hover:underline">
schema
</Link>{" "}
and uses components from a{" "}
<Link href="/docs/catalog" className="text-foreground hover:underline">
catalog
</Link>
. Specs can be:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Generated by AI in real-time</li>
<li>Stored in a database</li>
<li>Streamed progressively from a server</li>
<li>Hand-authored as JSON files</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
json-render is schema-agnostic — your specs can follow any JSON
structure you choose.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A basic spec using the{" "}
<code className="text-foreground">@json-render/react</code> schema. Note
the flat structure with a <code className="text-foreground">root</code>{" "}
key and <code className="text-foreground">elements</code> map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Welcome" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Hello, $data.user.name!" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A more complex spec with multiple nested elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "User Profile", "padding": "md" },
"children": ["row-1", "button-1"],
"parentKey": ""
},
"row-1": {
"key": "row-1",
"type": "Row",
"props": { "gap": "md" },
"children": ["avatar-1", "stack-1"],
"parentKey": "card-1"
},
"avatar-1": {
"key": "avatar-1",
"type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"children": [],
"parentKey": "row-1"
},
"stack-1": {
"key": "stack-1",
"type": "Stack",
"props": { "gap": "sm" },
"children": ["name-text", "email-text"],
"parentKey": "row-1"
},
"name-text": {
"key": "name-text",
"type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" },
"children": [],
"parentKey": "stack-1"
},
"email-text": {
"key": "email-text",
"type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" },
"children": [],
"parentKey": "stack-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Edit Profile" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A high-level spec using semantic blocks for page layouts:
</p>
<Code lang="json">{`{
"root": "page",
"elements": {
"page": {
"key": "page",
"type": "Page",
"props": {},
"children": ["header", "hero", "features", "footer"],
"parentKey": ""
},
"header": {
"key": "header",
"type": "Header",
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
"children": [],
"parentKey": "page"
},
"hero": {
"key": "hero",
"type": "Hero",
"props": {
"title": "Build UIs with JSON",
"subtitle": "Let AI generate your interfaces",
"ctaLabel": "Get Started",
"ctaHref": "/docs"
},
"children": [],
"parentKey": "page"
},
"features": {
"key": "features",
"type": "Features",
"props": { "columns": 3 },
"children": ["feature-1", "feature-2", "feature-3"],
"parentKey": "page"
},
"feature-1": {
"key": "feature-1",
"type": "Feature",
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
"children": [],
"parentKey": "features"
},
"feature-2": {
"key": "feature-2",
"type": "Feature",
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
"children": [],
"parentKey": "features"
},
"feature-3": {
"key": "feature-3",
"type": "Feature",
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
"children": [],
"parentKey": "features"
},
"footer": {
"key": "footer",
"type": "Footer",
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
"children": [],
"parentKey": "page"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
<p className="text-sm text-muted-foreground mb-4">
Every spec has a <code className="text-foreground">root</code> key
pointing to the entry element, and an{" "}
<code className="text-foreground">elements</code> map containing all
elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "My Card" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": { ... }
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
Each element in the map has a consistent shape:
</p>
<Code lang="json">{`{
"key": "unique-id",
"type": "ComponentName",
"props": { "label": "Hello" },
"children": ["child-1", "child-2"],
"parentKey": "parent-id"
}`}</Code>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
<li>
<code className="text-foreground">key</code> — Unique identifier for
this element
</li>
<li>
<code className="text-foreground">type</code> — Component type from
your catalog
</li>
<li>
<code className="text-foreground">props</code> — Component properties
</li>
<li>
<code className="text-foreground">children</code> — Array of child
element keys
</li>
<li>
<code className="text-foreground">parentKey</code> — Key of parent
element (empty string for root)
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
<p className="text-sm text-muted-foreground mb-4">
Props can reference data using{" "}
<code className="text-foreground">$data</code> paths:
</p>
<Code lang="json">{`{
"key": "metric-1",
"type": "Metric",
"props": {
"label": "Total Revenue",
"value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange"
},
"children": [],
"parentKey": "dashboard"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
<p className="text-sm text-muted-foreground mb-4">
Control when elements appear using the{" "}
<code className="text-foreground">visible</code> property:
</p>
<Code lang="json">{`{
"key": "alert-1",
"type": "Alert",
"props": {
"message": "You have unsaved changes"
},
"children": [],
"parentKey": "form",
"visible": {
"path": "$data.form.isDirty",
"operator": "eq",
"value": true
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function MyApp({ spec, data }) {
return (
<Renderer
spec={spec}
data={data}
registry={registry}
/>
);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
<Code lang="typescript">{`import { validate } from '@json-render/core';
const result = validate(spec, catalog);
if (!result.valid) {
console.error('Invalid spec:', result.errors);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
<p className="text-sm text-muted-foreground mb-4">
Specs can be streamed incrementally for progressive rendering:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function GenerativeUI() {
const { spec, isStreaming } = useUIStream({
api: '/api/generate',
});
return (
<Renderer
spec={spec}
registry={registry}
loading={isStreaming}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
<p className="text-sm text-muted-foreground mb-4">
Specs can come from various sources:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">AI Generation</strong> — LLMs
generate specs based on prompts and catalog
</li>
<li>
<strong className="text-foreground">Database</strong> — Store specs as
JSON and load dynamically
</li>
<li>
<strong className="text-foreground">API Response</strong> — Server
returns specs based on user/context
</li>
<li>
<strong className="text-foreground">Static Files</strong> — Pre-built
specs for known UI patterns
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/catalog" className="text-foreground hover:underline">
catalogs
</Link>{" "}
— the vocabulary of components available in your specs.
</p>
</article>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Streaming | json-render",
};
export default function StreamingPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Streaming</h1>
<p className="text-muted-foreground mb-8">
Progressively render UI as AI generates it.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream Format</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
format where each line is a JSON patch operation that progressively
builds your spec:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
The hook handles parsing and state management:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function App() {
const {
spec, // Current UI spec state
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
abort, // Function to cancel streaming
} = useUIStream({
api: '/api/generate',
onChunk: (chunk) => {}, // Optional: called for each chunk
onFinish: (spec) => {}, // Optional: called when complete
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
<p className="text-sm text-muted-foreground mb-4">
Supported operations:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li>
<code className="text-foreground">set</code> — Set the value at a path
(creates if needed)
</li>
<li>
<code className="text-foreground">add</code> — Add to an array at a
path
</li>
<li>
<code className="text-foreground">replace</code> — Replace value at a
path
</li>
<li>
<code className="text-foreground">remove</code> — Remove value at a
path
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
<p className="text-sm text-muted-foreground mb-4">
Paths use a key-based format for elements:
</p>
<Code lang="bash">{`/root -> Root element
/root/children -> Children of root
/elements/card-1 -> Element with key "card-1"
/elements/card-1/children -> Children of card-1`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
<p className="text-sm text-muted-foreground mb-4">
Ensure your API route streams properly:
</p>
<Code lang="typescript">{`import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: catalog.prompt(),
prompt,
});
// Return as a streaming response
return result.toTextStreamResponse();
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Progressive Rendering
</h2>
<p className="text-sm text-muted-foreground mb-4">
The Renderer automatically updates as the spec changes:
</p>
<Code lang="tsx">{`function App() {
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
return (
<div>
{isStreaming && <LoadingIndicator />}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<Code lang="tsx">{`function App() {
const { isStreaming, send, abort } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button onClick={() => send('Create dashboard')}>
Generate
</button>
{isStreaming && (
<button onClick={abort}>Cancel</button>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Low-Level SpecStream API
</h2>
<p className="text-sm text-muted-foreground mb-4">
For custom streaming implementations, use the SpecStream compiler
directly:
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
// Create a compiler for your spec type
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks from AI
async function processStream(reader: ReadableStreamDefaultReader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const { result, newPatches } = compiler.push(value);
if (newPatches.length > 0) {
// Update UI with partial result
setSpec(result);
}
}
// Get final compiled result
return compiler.getResult();
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
<p className="text-sm text-muted-foreground mb-4">
For non-streaming scenarios, compile entire SpecStream at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
</article>
);
}
@@ -18,16 +18,36 @@ export default function ValidationPage() {
json-render includes common validation functions:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li><code className="text-foreground">required</code> — Value must be non-empty</li>
<li><code className="text-foreground">email</code> — Valid email format</li>
<li><code className="text-foreground">minLength</code> — Minimum string length</li>
<li><code className="text-foreground">maxLength</code> — Maximum string length</li>
<li><code className="text-foreground">pattern</code> — Match a regex pattern</li>
<li><code className="text-foreground">min</code> — Minimum numeric value</li>
<li><code className="text-foreground">max</code> — Maximum numeric value</li>
<li>
<code className="text-foreground">required</code> — Value must be
non-empty
</li>
<li>
<code className="text-foreground">email</code> — Valid email format
</li>
<li>
<code className="text-foreground">minLength</code> — Minimum string
length
</li>
<li>
<code className="text-foreground">maxLength</code> — Maximum string
length
</li>
<li>
<code className="text-foreground">pattern</code> — Match a regex
pattern
</li>
<li>
<code className="text-foreground">min</code> — Minimum numeric value
</li>
<li>
<code className="text-foreground">max</code> — Maximum numeric value
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Validation in JSON</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using Validation in JSON
</h2>
<Code lang="json">{`{
"type": "TextField",
"props": {
@@ -41,7 +61,9 @@ export default function ValidationPage() {
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Validation with Parameters</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Validation with Parameters
</h2>
<Code lang="json">{`{
"type": "TextField",
"props": {
@@ -63,13 +85,19 @@ export default function ValidationPage() {
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Validation Functions</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Custom Validation Functions
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define custom validators in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
validationFunctions: {
functions: {
isValidPhone: {
description: 'Validates phone number format',
},
@@ -107,15 +135,15 @@ function App() {
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useFieldValidation } from '@json-render/react';
function TextField({ element }) {
function TextField({ props }) {
const { value, setValue, errors, validate } = useFieldValidation(
element.props.valuePath,
element.props.checks
props.valuePath,
props.checks
);
return (
<div>
<label>{element.props.label}</label>
<label>{props.label}</label>
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
@@ -130,17 +158,31 @@ function TextField({ element }) {
<h2 className="text-xl font-semibold mt-12 mb-4">Validation Timing</h2>
<p className="text-sm text-muted-foreground mb-4">
Control when validation runs with <code className="text-foreground">validateOn</code>:
Control when validation runs with{" "}
<code className="text-foreground">validateOn</code>:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
<li><code className="text-foreground">change</code> — Validate on every input change</li>
<li><code className="text-foreground">blur</code> — Validate when field loses focus</li>
<li><code className="text-foreground">submit</code> — Validate only on form submission</li>
<li>
<code className="text-foreground">change</code> — Validate on every
input change
</li>
<li>
<code className="text-foreground">blur</code> — Validate when field
loses focus
</li>
<li>
<code className="text-foreground">submit</code> — Validate only on
form submission
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/ai-sdk" className="text-foreground hover:underline">AI SDK integration</Link>.
Learn about{" "}
<Link href="/docs/ai-sdk" className="text-foreground hover:underline">
AI SDK integration
</Link>
.
</p>
</article>
);
@@ -29,7 +29,9 @@ function App() {
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Path-Based Visibility</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Path-Based Visibility
</h2>
<p className="text-sm text-muted-foreground mb-4">
Show/hide based on data values:
</p>
@@ -41,7 +43,9 @@ function App() {
// Visible when /form/hasErrors is truthy`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Auth-Based Visibility</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Auth-Based Visibility
</h2>
<p className="text-sm text-muted-foreground mb-4">
Show/hide based on authentication state:
</p>
@@ -120,8 +124,9 @@ function App() {
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
function ConditionalContent({ element, children }) {
const isVisible = useIsVisible(element.visible);
// The Renderer handles visibility automatically, but you can also use the hook
function ConditionalContent({ condition, children }) {
const isVisible = useIsVisible(condition);
if (!isVisible) return null;
return <div>{children}</div>;
@@ -129,7 +134,14 @@ function ConditionalContent({ element, children }) {
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/validation" className="text-foreground hover:underline">form validation</Link>.
Learn about{" "}
<Link
href="/docs/validation"
className="text-foreground hover:underline"
>
form validation
</Link>
.
</p>
</article>
);
+14
View File
@@ -0,0 +1,14 @@
import { Header } from "@/components/header";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}
@@ -10,16 +10,19 @@ export default function Home() {
{/* Hero */}
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
Predictable. Guardrailed. Fast.
AI → json-render → UI
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.
Define a component catalog. Users prompt. AI outputs JSON constrained
to your catalog. Your components render it.
</p>
<Demo />
<div className="flex items-center justify-center gap-2 border border-border rounded px-4 py-3 mt-12 mx-auto w-fit">
<code className="text-sm bg-transparent">npm install @json-render/core @json-render/react</code>
<code className="text-sm bg-transparent">
npm install @json-render/core @json-render/react
</code>
<CopyButton text="npm install @json-render/core @json-render/react" />
</div>
@@ -33,11 +36,7 @@ export default function Home() {
target="_blank"
rel="noopener noreferrer"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="w-5 h-5"
>
<svg viewBox="0 0 24 24" fill="currentColor" className="w-5 h-5">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
GitHub
@@ -51,24 +50,35 @@ export default function Home() {
<div className="max-w-5xl mx-auto px-6 py-24">
<div className="grid md:grid-cols-3 gap-12">
<div>
<div className="text-xs text-muted-foreground font-mono mb-3">01</div>
<h3 className="text-lg font-semibold mb-2">Define Your Catalog</h3>
<div className="text-xs text-muted-foreground font-mono mb-3">
01
</div>
<h3 className="text-lg font-semibold mb-2">
Define Your Catalog
</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Set the guardrails. Define which components, actions, and data bindings AI can use.
Set the guardrails. Define which components, actions, and data
bindings AI can use.
</p>
</div>
<div>
<div className="text-xs text-muted-foreground font-mono mb-3">02</div>
<div className="text-xs text-muted-foreground font-mono mb-3">
02
</div>
<h3 className="text-lg font-semibold mb-2">Users Prompt</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
End users describe what they want. AI generates JSON constrained to your catalog.
End users describe what they want. AI generates JSON constrained
to your catalog.
</p>
</div>
<div>
<div className="text-xs text-muted-foreground font-mono mb-3">03</div>
<div className="text-xs text-muted-foreground font-mono mb-3">
03
</div>
<h3 className="text-lg font-semibold mb-2">Render Instantly</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Stream the response. Your components render progressively as JSON arrives.
Stream the response. Your components render progressively as
JSON arrives.
</p>
</div>
</div>
@@ -79,8 +89,10 @@ export default function Home() {
<section className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-24">
<div className="grid lg:grid-cols-2 gap-12">
<div>
<h2 className="text-2xl font-semibold mb-4">Define your catalog</h2>
<div className="min-w-0">
<h2 className="text-2xl font-semibold mb-4">
Define your catalog
</h2>
<p className="text-muted-foreground mb-6">
Components, actions, and validation functions.
</p>
@@ -109,7 +121,7 @@ export const catalog = createCatalog({
},
});`}</Code>
</div>
<div>
<div className="min-w-0">
<h2 className="text-2xl font-semibold mb-4">AI generates JSON</h2>
<p className="text-muted-foreground mb-6">
Constrained output that your components render natively.
@@ -138,18 +150,126 @@ export const catalog = createCatalog({
</div>
</section>
{/* Code Export */}
<section className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-24">
<div className="text-center mb-12">
<h2 className="text-2xl font-semibold mb-4">Export as Code</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Export generated UI as standalone React components. No runtime
dependencies required.
</p>
</div>
<div className="grid lg:grid-cols-2 gap-12">
<div className="min-w-0">
<h3 className="text-lg font-semibold mb-4">Generated UI Tree</h3>
<p className="text-muted-foreground mb-6 text-sm">
AI generates a JSON structure from the user&apos;s prompt.
</p>
<Code lang="json">{`{
"root": "card",
"elements": {
"card": {
"key": "card",
"type": "Card",
"props": { "title": "Revenue" },
"children": ["metric", "chart"]
},
"metric": {
"key": "metric",
"type": "Metric",
"props": {
"label": "Total Revenue",
"valuePath": "analytics/revenue",
"format": "currency"
}
},
"chart": {
"key": "chart",
"type": "Chart",
"props": {
"dataPath": "analytics/salesByRegion"
}
}
}
}`}</Code>
</div>
<div className="min-w-0">
<h3 className="text-lg font-semibold mb-4">
Exported React Code
</h3>
<p className="text-muted-foreground mb-6 text-sm">
Export as a standalone Next.js project with all components.
</p>
<Code lang="tsx">{`"use client";
import { Card, Metric, Chart } from "@/components/ui";
const data = {
analytics: {
revenue: 125000,
salesByRegion: [
{ label: "US", value: 45000 },
{ label: "EU", value: 35000 },
],
},
};
export default function Page() {
return (
<Card data={data} title="Revenue">
<Metric
data={data}
label="Total Revenue"
valuePath="analytics/revenue"
format="currency"
/>
<Chart data={data} dataPath="analytics/salesByRegion" />
</Card>
);
}`}</Code>
</div>
</div>
<div className="mt-8 text-center">
<p className="text-sm text-muted-foreground">
The export includes{" "}
<code className="text-foreground">package.json</code>, component
files, styles, and everything needed to run independently.
</p>
</div>
</div>
</section>
{/* Features */}
<section className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-24">
<h2 className="text-2xl font-semibold mb-12 text-center">Features</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{ title: "Guardrails", desc: "AI can only use components you define in the catalog" },
{ title: "Streaming", desc: "Progressive rendering as JSON streams from the model" },
{ title: "Data Binding", desc: "Two-way binding with JSON Pointer paths" },
{ title: "Actions", desc: "Named actions handled by your application" },
{ title: "Visibility", desc: "Conditional show/hide based on data or auth" },
{ title: "Validation", desc: "Built-in and custom validation functions" },
{
title: "Guardrails",
desc: "AI can only use components you define in the catalog",
},
{
title: "Streaming",
desc: "Progressive rendering as JSON streams from the model",
},
{
title: "Code Export",
desc: "Export as standalone React code with no runtime dependencies",
},
{
title: "Data Binding",
desc: "Two-way binding with JSON Pointer paths",
},
{
title: "Actions",
desc: "Named actions handled by your application",
},
{
title: "Visibility",
desc: "Conditional show/hide based on data or auth",
},
].map((feature) => (
<div key={feature.title}>
<h3 className="font-semibold mb-2">{feature.title}</h3>
@@ -165,7 +285,9 @@ export const catalog = createCatalog({
<div className="max-w-4xl mx-auto px-6 py-24 text-center">
<h2 className="text-2xl font-semibold mb-4">Get started</h2>
<div className="flex items-center justify-center gap-2 border border-border rounded px-4 py-3 mb-8 mx-auto w-fit">
<code className="text-sm bg-transparent">npm install @json-render/core @json-render/react</code>
<code className="text-sm bg-transparent">
npm install @json-render/core @json-render/react
</code>
<CopyButton text="npm install @json-render/core @json-render/react" />
</div>
<div>
+65 -80
View File
@@ -1,93 +1,78 @@
import { streamText } from 'ai';
import { streamText } from "ai";
import { headers } from "next/headers";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
const SYSTEM_PROMPT = playgroundCatalog.prompt({
customRules: [
"For forms: Card should be the root element, not wrapped in a centering Stack",
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
"NEVER use page background colors (bg-gray-50) - container has its own background",
],
});
AVAILABLE COMPONENTS (22):
Layout:
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
- Divider: {} - Horizontal separator line
Form Inputs:
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
- Radio: { label: string, name: string, options: string[] } - Radio button group
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
Actions:
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
- Link: { label: string, href: string } - Anchor link
Typography:
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
Data Display:
- Image: { src: string, alt: string, width?: number, height?: number } - Image
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
- Rating: { value: number, max?: number, label?: string } - Star rating display
Charts:
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
OUTPUT FORMAT (JSONL):
{"op":"set","path":"/root","value":"element-key"}
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
RULES:
1. First line sets /root to root element key
2. Add elements with /elements/{key}
3. Children array contains string keys, not objects
4. Parent first, then children
5. Each element needs: key, type, props
6. Use className for custom Tailwind styling when needed
FORBIDDEN CLASSES (NEVER USE):
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
MOBILE-FIRST RESPONSIVE:
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
- Horizontal stacks that may overflow should use className:["flex-wrap"]
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
EXAMPLE (Blog with responsive grid):
{"op":"set","path":"/root","value":"page"}
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
Generate JSONL:`;
const MAX_PROMPT_LENGTH = 140;
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
export async function POST(req: Request) {
const { prompt } = await req.json();
// Get client IP for rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const sanitizedPrompt = String(prompt || '').slice(0, MAX_PROMPT_LENGTH);
// Check rate limits (minute and daily)
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { prompt, context } = await req.json();
const previousSpec = context?.previousSpec;
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
// Build the user prompt, including previous tree for iteration
let userPrompt = sanitizedPrompt;
if (
previousSpec &&
previousSpec.root &&
Object.keys(previousSpec.elements || {}).length > 0
) {
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
${JSON.stringify(previousSpec, null, 2)}
USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
}
const result = streamText({
model: 'anthropic/claude-opus-4.5',
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
system: SYSTEM_PROMPT,
prompt: sanitizedPrompt,
prompt: userPrompt,
temperature: 0.7,
});
-151
View File
@@ -1,151 +0,0 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Actions | json-render",
};
export default function ActionsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Actions</h1>
<p className="text-muted-foreground mb-8">
Handle user interactions safely with named actions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Why Named Actions?</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by name.
Your application provides the implementation. This is a core guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Defining Actions</h2>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
components: { /* ... */ },
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().optional(),
}).optional(),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">ActionProvider</h2>
<p className="text-sm text-muted-foreground mb-4">
Provide action handlers to your app:
</p>
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
function App() {
const handlers = {
submit_form: async (params) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
return response.json();
},
export_data: async (params) => {
const blob = await generateExport(params.format, params.filters);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
};
return (
<ActionProvider handlers={handlers}>
{/* Your UI */}
</ActionProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Actions in Components</h2>
<Code lang="tsx">{`const Button = ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
);
// Or use the useAction hook
import { useAction } from '@json-render/react';
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Actions with Confirmation</h2>
<p className="text-sm text-muted-foreground mb-4">
AI can declare actions that require user confirmation:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Delete Account",
"action": {
"name": "delete_account",
"params": { "userId": "123" },
"confirm": {
"title": "Delete Account?",
"message": "This action cannot be undone.",
"variant": "danger"
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Action Callbacks</h2>
<p className="text-sm text-muted-foreground mb-4">
Handle success and error states:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Save",
"action": {
"name": "save_changes",
"params": { "documentId": "doc-1" },
"onSuccess": {
"set": { "/ui/savedMessage": "Changes saved!" }
},
"onError": {
"set": { "/ui/errorMessage": "$error.message" }
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/visibility" className="text-foreground hover:underline">conditional visibility</Link>.
</p>
</article>
);
}
-110
View File
@@ -1,110 +0,0 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/core API | json-render",
};
export default function CoreApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
<p className="text-muted-foreground mb-8">
Core types, schemas, and utilities.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">createCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a catalog definition.
</p>
<Code lang="typescript">{`function createCatalog(config: CatalogConfig): Catalog
interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
validationFunctions?: Record<string, ValidationFunctionDef>;
}
interface ComponentDefinition {
props: ZodObject;
hasChildren?: boolean;
description?: string;
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">generateCatalogPrompt</h2>
<p className="text-sm text-muted-foreground mb-4">
Generates a system prompt for AI models.
</p>
<Code lang="typescript">{`function generateCatalogPrompt(catalog: Catalog): string`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
<p className="text-sm text-muted-foreground mb-4">
Evaluates a visibility condition against data and auth state.
</p>
<Code lang="typescript">{`function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
): boolean
type VisibilityCondition =
| { path: string }
| { auth: 'signedIn' | 'signedOut' | string }
| { and: VisibilityCondition[] }
| { or: VisibilityCondition[] }
| { not: VisibilityCondition }
| { eq: [DynamicValue, DynamicValue] }
| { gt: [DynamicValue, DynamicValue] }
| { gte: [DynamicValue, DynamicValue] }
| { lt: [DynamicValue, DynamicValue] }
| { lte: [DynamicValue, DynamicValue] };`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
<Code lang="typescript">{`interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">UITree</h3>
<Code lang="typescript">{`interface UITree {
root: UIElement | null;
elements: Record<string, UIElement>;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
<Code lang="typescript">{`interface Action {
name: string;
params?: Record<string, unknown>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
<Code lang="typescript">{`interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
fn: string;
args?: Record<string, unknown>;
message: string;
}`}</Code>
</article>
);
}
-101
View File
@@ -1,101 +0,0 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Catalog | json-render",
};
export default function CatalogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Catalog</h1>
<p className="text-muted-foreground mb-8">
The catalog defines what AI can generate. It&apos;s your guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
<p className="text-sm text-muted-foreground mb-4">
A catalog is a schema that defines:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li><strong className="text-foreground">Components</strong> — UI elements AI can create</li>
<li><strong className="text-foreground">Actions</strong> — Operations AI can trigger</li>
<li><strong className="text-foreground">Validation Functions</strong> — Custom validators for form inputs</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
const catalog = createCatalog({
components: {
// Define each component with its props schema
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
padding: z.enum(['sm', 'md', 'lg']).default('md'),
}),
hasChildren: true, // Can contain other components
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // JSON Pointer to data
format: z.enum(['currency', 'percent', 'number']),
}),
},
},
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
},
},
validationFunctions: {
isValidEmail: {
description: 'Validates email format',
},
isPhoneNumber: {
description: 'Validates phone number',
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the catalog has:
</p>
<Code lang="typescript">{`{
props: z.object({...}), // Zod schema for props
hasChildren?: boolean, // Can it have children?
description?: string, // Help AI understand when to use it
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Generating AI Prompts</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">generateCatalogPrompt</code> to create a system prompt for AI:
</p>
<Code lang="typescript">{`import { generateCatalogPrompt } from '@json-render/core';
const systemPrompt = generateCatalogPrompt(catalog);
// Pass this to your AI model as the system prompt`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn how to <Link href="/docs/components" className="text-foreground hover:underline">register React components</Link> for your catalog.
</p>
</article>
);
}
-104
View File
@@ -1,104 +0,0 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Components | json-render",
};
export default function ComponentsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Components</h1>
<p className="text-muted-foreground mb-8">
Register React components to render your catalog types.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a registry that maps catalog component types to React components:
</p>
<Code lang="tsx">{`const registry = {
Card: ({ element, children }) => (
<div className="card">
<h2>{element.props.title}</h2>
{element.props.description && (
<p>{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
),
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component receives these props:
</p>
<Code lang="typescript">{`interface ComponentProps {
element: {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
};
children?: React.ReactNode; // Rendered children
onAction: (name: string, params: object) => void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks to read and write data:
</p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
const Metric = ({ element }) => {
// Read-only value
const value = useDataValue(element.props.valuePath);
return (
<div className="metric">
<span className="label">{element.props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
};
const TextField = ({ element }) => {
// Two-way binding
const [value, setValue] = useDataBinding(element.props.valuePath);
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder={element.props.placeholder}
/>
);
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function App() {
return (
<Renderer
tree={uiTree}
registry={registry}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about <Link href="/docs/data-binding" className="text-foreground hover:underline">data binding</Link> for dynamic values.
</p>
</article>
);
}
-53
View File
@@ -1,53 +0,0 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Code } from "@/components/code";
export const metadata = {
title: "Installation | json-render",
};
export default function InstallationPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Installation</h1>
<p className="text-muted-foreground mb-8">
Install the core and React packages to get started.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
<Code lang="bash">npm install @json-render/core @json-render/react</Code>
<p className="text-sm text-muted-foreground mb-4">Or with other package managers:</p>
<Code lang="bash">{`# pnpm
pnpm add @json-render/core @json-render/react
# yarn
yarn add @json-render/core @json-render/react
# bun
bun add @json-render/core @json-render/react`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render requires the following peer dependencies:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li><code className="text-foreground">react</code> ^19.0.0</li>
<li><code className="text-foreground">zod</code> ^3.0.0</li>
</ul>
<Code lang="bash">npm install react zod</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
<p className="text-sm text-muted-foreground mb-4">
To use json-render with AI models, you&apos;ll also need the Vercel AI SDK:
</p>
<Code lang="bash">npm install ai</Code>
<div className="flex gap-3 mt-12">
<Button size="sm" asChild>
<Link href="/docs/quick-start">Quick Start</Link>
</Button>
</div>
</article>
);
}
-77
View File
@@ -1,77 +0,0 @@
import Link from "next/link";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
],
},
];
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="max-w-5xl mx-auto px-6 py-12 flex gap-16">
{/* Sidebar */}
<aside className="w-48 flex-shrink-0 hidden lg:block">
<nav className="sticky top-20 space-y-6">
{navigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors block py-1"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">
{children}
</div>
</div>
);
}
-171
View File
@@ -1,171 +0,0 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Code } from "@/components/code";
export const metadata = {
title: "Quick Start | json-render",
};
export default function QuickStartPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Quick Start</h1>
<p className="text-muted-foreground mb-8">
Get up and running with json-render in 5 minutes.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">1. Define your catalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog that defines what components AI can use:
</p>
<Code lang="typescript">{`// lib/catalog.ts
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const catalog = createCatalog({
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
hasChildren: true,
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
},
Text: {
props: z.object({
content: z.string(),
}),
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
},
navigate: {
params: z.object({ url: z.string() }),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">2. Create your components</h2>
<p className="text-sm text-muted-foreground mb-4">
Register React components that render each catalog type:
</p>
<Code lang="tsx">{`// components/registry.tsx
export const registry = {
Card: ({ element, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{element.props.title}</h2>
{element.props.description && (
<p className="text-gray-600">{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction(element.props.action, {})}
>
{element.props.label}
</button>
),
Text: ({ element }) => (
<p>{element.props.content}</p>
),
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">3. Create an API route</h2>
<p className="text-sm text-muted-foreground mb-4">
Set up a streaming API route for AI generation:
</p>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
const result = streamText({
model: 'anthropic/claude-opus-4.5',
system: systemPrompt,
prompt,
});
return new Response(result.textStream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { registry } from '@/components/registry';
export default function Page() {
const { tree, isLoading, generate } = useUIStream({
endpoint: '/api/generate',
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
generate(formData.get('prompt') as string);
};
return (
<DataProvider initialData={{}}>
<VisibilityProvider>
<ActionProvider handlers={{
submit: (params) => console.log('Submit:', params),
navigate: (params) => console.log('Navigate:', params),
}}>
<form onSubmit={handleSubmit}>
<input
name="prompt"
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isLoading}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer tree={tree} registry={registry} />
</div>
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
<li>Learn about <Link href="/docs/catalog" className="text-foreground hover:underline">catalogs</Link> in depth</li>
<li>Explore <Link href="/docs/data-binding" className="text-foreground hover:underline">data binding</Link> for dynamic values</li>
<li>Add <Link href="/docs/actions" className="text-foreground hover:underline">actions</Link> for interactivity</li>
<li>Implement <Link href="/docs/visibility" className="text-foreground hover:underline">conditional visibility</Link></li>
</ul>
<div className="flex gap-3 mt-12">
<Button size="sm" asChild>
<Link href="/docs/catalog">Learn about Catalogs</Link>
</Button>
</div>
</article>
);
}
-119
View File
@@ -1,119 +0,0 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Streaming | json-render",
};
export default function StreamingPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Streaming</h1>
<p className="text-muted-foreground mb-8">
Progressively render UI as AI generates it.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">How Streaming Works</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses JSONL (JSON Lines) streaming. As AI generates,
each line represents a patch operation:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
The hook handles parsing and state management:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function App() {
const {
tree, // Current UI tree state
isLoading, // True while streaming
error, // Any error that occurred
generate, // Function to start generation
abort, // Function to cancel streaming
} = useUIStream({
endpoint: '/api/generate',
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
<p className="text-sm text-muted-foreground mb-4">
Supported operations:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li><code className="text-foreground">set</code> — Set the value at a path (creates if needed)</li>
<li><code className="text-foreground">add</code> — Add to an array at a path</li>
<li><code className="text-foreground">replace</code> — Replace value at a path</li>
<li><code className="text-foreground">remove</code> — Remove value at a path</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
<p className="text-sm text-muted-foreground mb-4">
Paths use a key-based format for elements:
</p>
<Code lang="bash">{`/root -> Root element
/root/children -> Children of root
/elements/card-1 -> Element with key "card-1"
/elements/card-1/children -> Children of card-1`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
<p className="text-sm text-muted-foreground mb-4">
Ensure your API route streams properly:
</p>
<Code lang="typescript">{`export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-opus-4.5',
system: generateCatalogPrompt(catalog),
prompt,
});
// Return as a streaming response
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
},
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Progressive Rendering</h2>
<p className="text-sm text-muted-foreground mb-4">
The Renderer automatically updates as the tree changes:
</p>
<Code lang="tsx">{`function App() {
const { tree, isLoading } = useUIStream({ endpoint: '/api/generate' });
return (
<div>
{isLoading && <LoadingIndicator />}
<Renderer tree={tree} registry={registry} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<Code lang="tsx">{`function App() {
const { isLoading, generate, abort } = useUIStream({
endpoint: '/api/generate',
});
return (
<div>
<button onClick={() => generate('Create dashboard')}>
Generate
</button>
{isLoading && (
<button onClick={abort}>Cancel</button>
)}
</div>
);
}`}</Code>
</article>
);
}
+4 -2
View File
@@ -5,6 +5,7 @@
:root {
--radius: 0.5rem;
--ds-gray-500: oklch(0.836 0 0);
/* Monochrome light theme */
--background: oklch(1.0 0 0);
--foreground: oklch(0.1 0 0);
@@ -28,6 +29,7 @@
}
.dark {
--ds-gray-500: oklch(0.39 0 0);
/* Monochrome dark theme */
--background: oklch(0.0 0 0);
--foreground: oklch(0.98 0 0);
@@ -45,8 +47,8 @@
--accent-foreground: oklch(0.98 0 0);
--destructive: oklch(0.65 0.2 25);
--destructive-foreground: oklch(0.98 0 0);
--border: oklch(0.2 0 0);
--input: oklch(0.2 0 0);
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
}
+51 -11
View File
@@ -1,10 +1,9 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { ThemeProvider } from "@/components/theme-provider";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
@@ -16,9 +15,55 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
title: "json-render | AI-powered UI from JSON with guardrails",
metadataBase: new URL("https://json-render.dev"),
title: {
default: "json-render | AI-generated UI with guardrails",
template: "%s | json-render",
},
description:
"Define a catalog. AI generates JSON. Your components render natively. Enterprise-grade guardrails for controlled AI UI generation.",
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
keywords: [
"json-render",
"AI UI generation",
"React components",
"guardrails",
"structured output",
"dashboard builder",
],
authors: [{ name: "Vercel Labs" }],
creator: "Vercel Labs",
openGraph: {
type: "website",
locale: "en_US",
url: "https://json-render.dev",
siteName: "json-render",
title: "json-render | AI-generated UI with guardrails",
description:
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
images: [
{
url: "/og",
width: 1200,
height: 630,
alt: "json-render - AI-generated UI with guardrails",
},
],
},
twitter: {
card: "summary_large_image",
title: "json-render | AI-generated UI with guardrails",
description:
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
robots: {
index: true,
follow: true,
},
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({
@@ -29,14 +74,9 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
</ThemeProvider>
<ThemeProvider>{children}</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
</html>
);
+44
View File
@@ -0,0 +1,44 @@
import { ImageResponse } from "next/og";
export async function GET(request: Request) {
const geist = await fetch(new URL("/Geist-Regular.ttf", request.url)).then(
(res) => res.arrayBuffer(),
);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "black",
}}
>
<span
style={{
fontSize: 144,
fontFamily: "Geist",
fontWeight: 400,
color: "white",
letterSpacing: "-0.02em",
}}
>
json-render
</span>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "Geist",
data: geist,
style: "normal",
weight: 400,
},
],
},
);
}
+7
View File
@@ -0,0 +1,7 @@
export default function PlaygroundLayout({
children,
}: {
children: React.ReactNode;
}) {
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
}
+2 -65
View File
@@ -1,72 +1,9 @@
import { Button } from "@/components/ui/button";
import { Playground } from "@/components/playground";
export const metadata = {
title: "Playground | json-render",
};
export default function PlaygroundPage() {
return (
<div className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-4">Playground</h1>
<p className="text-muted-foreground mb-12">
Try json-render with a live example.
</p>
<div className="space-y-12">
<section>
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
<p className="text-sm text-muted-foreground mb-4">
Clone the repository and run the example dashboard.
</p>
<pre className="text-sm mb-4">
<code>{`git clone https://github.com/vercel-labs/json-render
cd json-render
pnpm install
pnpm dev`}</code>
</pre>
<p className="text-sm text-muted-foreground">
Open <code>http://localhost:3001</code> for the example dashboard.
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
<p className="text-sm text-muted-foreground mb-4">
Try these prompts in the example dashboard:
</p>
<div className="space-y-2">
{[
"Create a revenue dashboard with monthly metrics",
"Build a user management panel with a table",
"Design a settings form with text inputs",
"Make a notification center with alerts",
].map((prompt) => (
<div
key={prompt}
className="p-3 border border-border rounded text-sm font-mono"
>
{prompt}
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
<p className="text-sm text-muted-foreground mb-6">
A browser-based playground is coming soon.
</p>
<Button variant="outline" asChild>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
</section>
</div>
</div>
);
return <Playground />;
}
+49 -22
View File
@@ -21,7 +21,11 @@ const vercelDarkTheme = {
settings: { foreground: "#50E3C2" },
},
{
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#50E3C2" },
},
{
@@ -49,7 +53,11 @@ const vercelDarkTheme = {
settings: { foreground: "#888888" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#EDEDED" },
},
{
@@ -80,7 +88,11 @@ const vercelLightTheme = {
settings: { foreground: "#067a6e" },
},
{
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#067a6e" },
},
{
@@ -108,7 +120,11 @@ const vercelLightTheme = {
settings: { foreground: "#6b7280" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#171717" },
},
{
@@ -143,38 +159,49 @@ if (typeof window !== "undefined") {
interface CodeBlockProps {
code: string;
lang: "json" | "tsx" | "typescript";
fillHeight?: boolean;
hideCopyButton?: boolean;
}
export function CodeBlock({ code, lang }: CodeBlockProps) {
export function CodeBlock({
code,
lang,
fillHeight,
hideCopyButton,
}: CodeBlockProps) {
const [html, setHtml] = useState<string>("");
useEffect(() => {
getHighlighter().then((highlighter) => {
setHtml(highlighter.codeToHtml(code, {
lang,
themes: {
light: "vercel-light",
dark: "vercel-dark",
},
defaultColor: false,
}));
setHtml(
highlighter.codeToHtml(code, {
lang,
themes: {
light: "vercel-light",
dark: "vercel-dark",
},
defaultColor: false,
}),
);
});
}, [code, lang]);
if (!html) {
return null;
return fillHeight ? <div className="p-3" /> : null;
}
return (
<div className="relative group">
<div className="sticky top-0 float-right z-10">
<CopyButton
text={code}
className="opacity-0 group-hover:opacity-100 text-neutral-400"
/>
</div>
<div className={`relative group ${fillHeight ? "p-3" : ""}`}>
{!hideCopyButton && (
<div className="float-right sticky top-3 z-10 ml-2">
<CopyButton
text={code}
className="opacity-0 group-hover:opacity-100 text-neutral-400"
/>
</div>
)}
<div
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
className="text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[13px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[13px]!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { CopyButton } from "./copy-button";
interface CodeTabsProps {
tabs: {
label: string;
value: string;
code: string;
html: string;
}[];
defaultValue?: string;
}
export function CodeTabs({ tabs, defaultValue }: CodeTabsProps) {
const defaultTab = defaultValue ?? tabs[0]?.value;
return (
<div className="my-6 rounded-lg border border-border bg-neutral-100 dark:bg-[#0a0a0a] text-sm font-mono overflow-hidden">
<Tabs defaultValue={defaultTab} className="gap-0">
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<TabsList className="h-7 bg-transparent p-0 gap-2">
{tabs.map((tab) => (
<TabsTrigger
key={tab.value}
value={tab.value}
className="h-6 px-2 text-xs data-[state=active]:bg-secondary data-[state=active]:shadow-none rounded"
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
{tabs.map((tab) => (
<TabsContent
key={tab.value}
value={tab.value}
className="relative group mt-0"
>
<div className="absolute top-3 right-3 z-10">
<CopyButton
text={tab.code}
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
/>
</div>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: tab.html }}
/>
</TabsContent>
))}
</Tabs>
</div>
);
}
+28 -6
View File
@@ -1,5 +1,6 @@
import { codeToHtml } from "shiki";
import { CopyButton } from "./copy-button";
import { ExpandableCode } from "./expandable-code";
const vercelDarkTheme = {
name: "vercel-dark",
@@ -18,7 +19,11 @@ const vercelDarkTheme = {
settings: { foreground: "#50E3C2" },
},
{
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#50E3C2" },
},
{
@@ -46,7 +51,11 @@ const vercelDarkTheme = {
settings: { foreground: "#888888" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#EDEDED" },
},
{
@@ -77,7 +86,11 @@ const vercelLightTheme = {
settings: { foreground: "#067a6e" },
},
{
scope: ["constant.numeric", "constant.language.boolean", "constant.language.null"],
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#067a6e" },
},
{
@@ -105,7 +118,11 @@ const vercelLightTheme = {
settings: { foreground: "#6b7280" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json", "meta.object-literal.key"],
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#171717" },
},
{
@@ -135,14 +152,19 @@ export async function Code({ children, lang = "typescript" }: CodeProps) {
});
return (
<div className="group relative my-6 rounded-lg border border-border bg-neutral-100 dark:bg-[#0a0a0a] text-sm font-mono overflow-hidden">
<div className="group relative my-6 rounded-lg border border-border bg-neutral-100 dark:bg-[#0a0a0a] text-sm font-mono overflow-hidden max-w-full">
<div className="absolute top-3 right-3 z-10">
<CopyButton
text={children.trim()}
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
/>
</div>
<div className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!" dangerouslySetInnerHTML={{ __html: html }} />
<ExpandableCode>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</ExpandableCode>
</div>
);
}
+20 -2
View File
@@ -23,11 +23,29 @@ export function CopyButton({ text, className = "" }: CopyButtonProps) {
aria-label="Copy code"
>
{copied ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
+1281 -125
View File
File diff suppressed because it is too large Load Diff
-27
View File
@@ -1,27 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Alert({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const alertType = props.type as string;
const alertClass =
alertType === "success"
? "bg-green-50 border-green-200"
: alertType === "warning"
? "bg-yellow-50 border-yellow-200"
: alertType === "error"
? "bg-red-50 border-red-200"
: "bg-blue-50 border-blue-200";
return (
<div className={`p-2 rounded border ${alertClass} ${baseClass} ${customClass}`}>
<div className="text-xs font-medium">{props.title as string}</div>
{props.message ? (
<div className="text-[10px] mt-0.5">{props.message as string}</div>
) : null}
</div>
);
}
-30
View File
@@ -1,30 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Avatar({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const name = (props.name as string) || "?";
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
const avatarSize =
props.size === "lg"
? "w-10 h-10 text-sm"
: props.size === "sm"
? "w-6 h-6 text-[8px]"
: "w-8 h-8 text-[10px]";
return (
<div
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass} ${customClass}`}
>
{initials}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Badge({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const badgeVariant = props.variant as string;
const badgeClass =
badgeVariant === "success"
? "bg-green-100 text-green-800"
: badgeVariant === "warning"
? "bg-yellow-100 text-yellow-800"
: badgeVariant === "danger"
? "bg-red-100 text-red-800"
: "bg-muted text-foreground";
return (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
>
{props.text as string}
</span>
);
}
-43
View File
@@ -1,43 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function BarGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="flex gap-1">
{data.map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="text-[8px] text-muted-foreground">
{d.value}
</div>
<div className="w-full h-20 flex items-end">
<div
className="w-full bg-foreground/80 rounded-t transition-all"
style={{ height: `${(d.value / maxValue) * 100}%`, minHeight: 2 }}
/>
</div>
<div className="text-[8px] text-muted-foreground truncate w-full text-center">
{d.label}
</div>
</div>
))}
</div>
</div>
);
}
-32
View File
@@ -1,32 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Button({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const variant = props.variant as string;
const label = props.label as string;
const actionText = (props.actionText as string) || label;
const btnClass =
variant === "danger"
? "bg-red-500 text-white"
: variant === "secondary"
? "bg-card border border-border text-foreground"
: "bg-foreground text-background";
return (
<button
type="button"
onClick={() =>
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction?.(actionText)
}
className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}
>
{label}
</button>
);
}
-36
View File
@@ -1,36 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Card({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-xs sm:min-w-[280px]"
: props.maxWidth === "md"
? "max-w-sm sm:min-w-[320px]"
: props.maxWidth === "lg"
? "max-w-md sm:min-w-[360px]"
: "w-full";
const centeredClass = props.centered ? "mx-auto" : "";
return (
<div
className={`border border-border rounded-lg p-3 bg-background overflow-hidden ${maxWidthClass} ${centeredClass} ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-1 text-left">
{props.title as string}
</div>
) : null}
{props.description ? (
<div className="text-[10px] text-muted-foreground mb-2 text-left">
{props.description as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Checkbox({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-background"}`}
>
{checked && (
<svg
className="w-2.5 h-2.5 text-background"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
{props.label as string}
</label>
);
}
-9
View File
@@ -1,9 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Divider({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return <hr className={`border-border my-2 ${baseClass} ${customClass}`} />;
}
-15
View File
@@ -1,15 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Fallback({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return (
<div
className={`text-[10px] text-muted-foreground ${baseClass} ${customClass}`}
>
[{element.type}]
</div>
);
}
-22
View File
@@ -1,22 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Form({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div
className={`border border-border rounded-lg p-3 bg-background ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-2 text-left">
{props.title as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Grid({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomCols = customClass.includes("grid-cols-");
const cols = hasCustomCols
? ""
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
const gridGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div className={`grid ${cols} ${gridGap} ${baseClass} ${customClass}`}>
{children}
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Heading({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const level = (props.level as number) || 2;
const headingClass =
level === 1
? "text-lg font-bold"
: level === 3
? "text-xs font-semibold"
: level === 4
? "text-[10px] font-semibold"
: "text-sm font-semibold";
return (
<div className={`${headingClass} text-left ${baseClass} ${customClass}`}>
{props.text as string}
</div>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Image({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomSize =
customClass.includes("w-") || customClass.includes("h-");
const imgStyle = hasCustomSize
? {}
: {
width: (props.width as number) || 80,
height: (props.height as number) || 60,
};
return (
<div
className={`bg-muted border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
style={imgStyle}
>
{(props.alt as string) || "img"}
</div>
);
}
-83
View File
@@ -1,83 +0,0 @@
"use client";
export type { ComponentRenderProps, ComponentRegistry } from "./types";
export { useInteractiveState } from "./utils";
export { Alert } from "./alert";
export { Avatar } from "./avatar";
export { Badge } from "./badge";
export { BarGraph } from "./bar-graph";
export { Button } from "./button";
export { Card } from "./card";
export { Checkbox } from "./checkbox";
export { Divider } from "./divider";
export { Fallback } from "./fallback";
export { Form } from "./form";
export { Grid } from "./grid";
export { Heading } from "./heading";
export { Image } from "./image";
export { Input } from "./input";
export { LineGraph } from "./line-graph";
export { Link } from "./link";
export { Progress } from "./progress";
export { Radio } from "./radio";
export { Rating } from "./rating";
export { Select } from "./select";
export { Stack } from "./stack";
export { Switch } from "./switch";
export { Text } from "./text";
export { Textarea } from "./textarea";
import type { ComponentRegistry } from "./types";
import { Alert } from "./alert";
import { Avatar } from "./avatar";
import { Badge } from "./badge";
import { BarGraph } from "./bar-graph";
import { Button } from "./button";
import { Card } from "./card";
import { Checkbox } from "./checkbox";
import { Divider } from "./divider";
import { Fallback } from "./fallback";
import { Form } from "./form";
import { Grid } from "./grid";
import { Heading } from "./heading";
import { Image } from "./image";
import { Input } from "./input";
import { LineGraph } from "./line-graph";
import { Link } from "./link";
import { Progress } from "./progress";
import { Radio } from "./radio";
import { Rating } from "./rating";
import { Select } from "./select";
import { Stack } from "./stack";
import { Switch } from "./switch";
import { Text } from "./text";
import { Textarea } from "./textarea";
export const demoRegistry: ComponentRegistry = {
Alert,
Avatar,
Badge,
BarGraph,
Button,
Card,
Checkbox,
Divider,
Form,
Grid,
Heading,
Image,
Input,
LineGraph,
Link,
Progress,
Radio,
Rating,
Select,
Stack,
Switch,
Text,
Textarea,
};
export const fallbackComponent = Fallback;
-24
View File
@@ -1,24 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Input({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<input
type={(props.type as string) || "text"}
placeholder={(props.placeholder as string) || ""}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
-116
View File
@@ -1,116 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function LineGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
const range = maxValue - minValue || 1;
// SVG dimensions with padding
const width = 300;
const height = 100;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Calculate points for the SVG path
const points = data.map((d, i) => {
const x = padding.left + (data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2);
const y = padding.top + chartHeight - ((d.value - minValue) / range) * chartHeight;
return { x, y, ...d };
});
const pathD =
points.length > 0
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
: "";
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="relative h-24">
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full"
>
{/* Grid lines */}
<line
x1={padding.left}
y1={padding.top + chartHeight / 2}
x2={width - padding.right}
y2={padding.top + chartHeight / 2}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top}
x2={width - padding.right}
y2={padding.top}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
{/* Line */}
{pathD && (
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-foreground/80"
/>
)}
{/* Points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
className="fill-foreground"
/>
))}
</svg>
</div>
{data.length > 0 && (
<div className="flex justify-between mt-1">
{data.map((d, i) => (
<div
key={i}
className="text-[8px] text-muted-foreground text-center"
style={{ width: `${100 / data.length}%` }}
>
{d.label}
</div>
))}
</div>
)}
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Link({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<span
className={`text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors underline-offset-2 hover:underline ${baseClass} ${customClass}`}
>
{props.label as string}
</span>
);
}
-26
View File
@@ -1,26 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Progress({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const value = Math.min(100, Math.max(0, (props.value as number) || 0));
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-foreground rounded-full transition-all"
style={{ width: `${value}%` }}
/>
</div>
</div>
);
}
-38
View File
@@ -1,38 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Radio({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const [selected, setSelected] = useState(0);
return (
<div className={`space-y-1 ${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
{options.map((opt, i) => (
<label
key={i}
className="flex items-center gap-2 text-xs cursor-pointer"
onClick={() => setSelected(i)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-full flex items-center justify-center transition-colors ${selected === i ? "border-foreground" : ""}`}
>
{selected === i && (
<div className="w-2 h-2 rounded-full bg-foreground" />
)}
</div>
{opt}
</label>
))}
</div>
);
}
-31
View File
@@ -1,31 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Rating({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const ratingValue = (props.value as number) || 0;
const maxRating = (props.max as number) || 5;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="flex gap-0.5">
{Array.from({ length: maxRating }).map((_, i) => (
<span
key={i}
className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
>
*
</span>
))}
</div>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import {
baseClass,
getCustomClass,
getOpenSelect,
setOpenSelectValue,
getSelectValue,
setSelectValueForKey,
} from "./utils";
export function Select({ element }: ComponentRenderProps) {
const { props, key } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const selectedValue = getSelectValue(key);
const isOpen = getOpenSelect() === key;
return (
<div className={`relative ${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<div
onClick={() => setOpenSelectValue(isOpen ? null : key)}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
>
<span
className={
selectedValue ? "text-foreground" : "text-muted-foreground/50"
}
>
{selectedValue || (props.placeholder as string) || "Select..."}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && options.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-background border border-border rounded shadow-lg overflow-hidden">
{options.map((opt, i) => (
<div
key={i}
onClick={() => {
setSelectValueForKey(key, opt);
setOpenSelectValue(null);
}}
className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
>
{opt}
</div>
))}
</div>
)}
</div>
);
}
-20
View File
@@ -1,20 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Stack({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const isHorizontal = props.direction === "horizontal";
const stackGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
>
{children}
</div>
);
}
-27
View File
@@ -1,27 +0,0 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Switch({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center justify-between gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<span>{props.label as string}</span>
<div
className={`w-8 h-4 rounded-full relative transition-colors ${checked ? "bg-foreground" : "bg-border"}`}
>
<div
className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${checked ? "right-0.5" : "left-0.5"}`}
/>
</div>
</label>
);
}
-22
View File
@@ -1,22 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Text({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const textVariant = props.variant as string;
const textClass =
textVariant === "caption"
? "text-[10px]"
: textVariant === "muted"
? "text-xs text-muted-foreground"
: "text-xs";
return (
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
{props.content as string}
</p>
);
}
-25
View File
@@ -1,25 +0,0 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Textarea({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const rows = (props.rows as number) || 3;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<textarea
placeholder={(props.placeholder as string) || ""}
rows={rows}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
-14
View File
@@ -1,14 +0,0 @@
import type { ReactNode } from "react";
import type { UIElement, Action } from "@json-render/core";
export interface ComponentRenderProps {
element: UIElement;
children?: ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}
export type ComponentRegistry = Record<
string,
React.ComponentType<ComponentRenderProps>
>;
-52
View File
@@ -1,52 +0,0 @@
"use client";
import { useState } from "react";
// Shared animation class
export const baseClass =
"animate-in fade-in slide-in-from-bottom-1 duration-200";
// Helper to get custom classes
export function getCustomClass(props: Record<string, unknown>): string {
return Array.isArray(props.className)
? (props.className as string[]).join(" ")
: "";
}
// State for interactive components
let openSelect: string | null = null;
let setOpenSelect: (v: string | null) => void = () => {};
let selectValues: Record<string, string> = {};
let setSelectValues: (
fn: (prev: Record<string, string>) => Record<string, string>
) => void = () => {};
export function useInteractiveState() {
const [_openSelect, _setOpenSelect] = useState<string | null>(null);
const [_selectValues, _setSelectValues] = useState<Record<string, string>>(
{}
);
openSelect = _openSelect;
setOpenSelect = _setOpenSelect;
selectValues = _selectValues;
setSelectValues = _setSelectValues;
return { openSelect, selectValues };
}
export function getOpenSelect() {
return openSelect;
}
export function setOpenSelectValue(v: string | null) {
setOpenSelect(v);
}
export function getSelectValue(key: string) {
return selectValues[key];
}
export function setSelectValueForKey(key: string, value: string) {
setSelectValues((prev) => ({ ...prev, [key]: value }));
}
+85
View File
@@ -0,0 +1,85 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { List } from "lucide-react";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allDocsPages.find((page) => page.href === pathname);
return page ?? allDocsPages[0];
}, [pathname]);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger className="lg:hidden sticky top-[calc(3.5rem+1px)] z-40 w-full px-6 py-3 bg-background/80 backdrop-blur-sm border-b border-border flex items-center justify-between focus:outline-none">
<div className="text-sm font-medium">{currentPage?.title}</div>
<div className="w-8 h-8 flex items-center justify-center">
<List className="h-4 w-4 text-muted-foreground" />
</div>
</SheetTrigger>
<SheetContent className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => {
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
} ${isExternal ? "inline-flex items-center gap-1" : ""}`}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
</SheetContent>
</Sheet>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { docsNavigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={cn(
"text-sm transition-colors block py-1",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isExternal && "inline-flex items-center gap-1",
)}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ExpandableCodeProps {
children: React.ReactNode;
maxHeight?: number;
}
export function ExpandableCode({
children,
maxHeight = 300,
}: ExpandableCodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setNeedsExpansion(contentRef.current.scrollHeight > maxHeight);
}
}, [maxHeight]);
return (
<div className="relative">
<div
ref={contentRef}
className="overflow-hidden transition-[max-height] duration-300"
style={{
maxHeight: isExpanded || !needsExpansion ? "none" : maxHeight,
}}
>
{children}
</div>
{needsExpansion && !isExpanded && (
<>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-neutral-100 dark:from-[#0a0a0a] to-transparent pointer-events-none" />
<button
onClick={() => setIsExpanded(true)}
className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700 rounded-md transition-colors"
>
Show all
</button>
</>
)}
</div>
);
}
-24
View File
@@ -1,24 +0,0 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-6 flex justify-between items-center text-sm text-muted-foreground">
<div>json-render</div>
<div className="flex gap-6">
<Link href="/docs" className="hover:text-foreground transition-colors">
Docs
</Link>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</a>
</div>
</div>
</footer>
);
}
+88 -9
View File
@@ -1,17 +1,88 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./theme-toggle";
import { cn } from "@/lib/utils";
export function Header() {
const pathname = usePathname();
const isActive = (href: string) => {
if (href === "/playground") {
return pathname === "/playground";
}
if (href === "/docs") {
return pathname.startsWith("/docs");
}
return false;
};
return (
<header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
<div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
<Link href="/" className="font-semibold hover:opacity-70 transition-opacity">
json-render
</Link>
<nav className="flex gap-4 items-center text-sm">
<header className="sticky top-0 z-50 bg-background">
<div className="flex h-14 items-center justify-between px-4 gap-6">
<div className="flex items-center gap-2">
<Link href="https://vercel.com" title="Made with love by Vercel">
<svg
data-testid="geist-icon"
height="18"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="18"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 1L16 15H0L8 1Z"
fill="currentColor"
></path>
</svg>
</Link>
<span className="text-(--ds-gray-500)">
<svg
data-testid="geist-icon"
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
fill="currentColor"
></path>
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
json-render
</span>
</Link>
</div>
<nav className="flex items-center gap-4">
<Link
href="/playground"
className={cn(
"text-sm transition-colors",
isActive("/playground")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="sm:hidden">Play</span>
<span className="hidden sm:inline">Playground</span>
</Link>
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground transition-colors"
className={cn(
"text-sm transition-colors",
isActive("/docs")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
Docs
</Link>
@@ -19,9 +90,17 @@ export function Header() {
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
<svg
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<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>10k</span>
</a>
<ThemeToggle />
</nav>
+171
View File
@@ -0,0 +1,171 @@
import { codeToHtml } from "shiki";
import { CodeTabs } from "./code-tabs";
const vercelDarkTheme = {
name: "vercel-dark",
type: "dark" as const,
colors: {
"editor.background": "transparent",
"editor.foreground": "#EDEDED",
},
settings: [
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#666666" },
},
{
scope: ["string", "string.quoted", "string.template"],
settings: { foreground: "#50E3C2" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#50E3C2" },
},
{
scope: ["keyword", "storage.type", "storage.modifier"],
settings: { foreground: "#FF0080" },
},
{
scope: ["keyword.operator", "keyword.control"],
settings: { foreground: "#FF0080" },
},
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#7928CA" },
},
{
scope: ["variable", "variable.other", "variable.parameter"],
settings: { foreground: "#EDEDED" },
},
{
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
settings: { foreground: "#FF0080" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#888888" },
},
{
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#EDEDED" },
},
{
scope: ["entity.other.attribute-name"],
settings: { foreground: "#50E3C2" },
},
{
scope: ["support.type.primitive", "entity.name.type.primitive"],
settings: { foreground: "#50E3C2" },
},
],
};
const vercelLightTheme = {
name: "vercel-light",
type: "light" as const,
colors: {
"editor.background": "transparent",
"editor.foreground": "#171717",
},
settings: [
{
scope: ["comment", "punctuation.definition.comment"],
settings: { foreground: "#6b7280" },
},
{
scope: ["string", "string.quoted", "string.template"],
settings: { foreground: "#067a6e" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#067a6e" },
},
{
scope: ["keyword", "storage.type", "storage.modifier"],
settings: { foreground: "#d6409f" },
},
{
scope: ["keyword.operator", "keyword.control"],
settings: { foreground: "#d6409f" },
},
{
scope: ["entity.name.function", "support.function", "meta.function-call"],
settings: { foreground: "#6e56cf" },
},
{
scope: ["variable", "variable.other", "variable.parameter"],
settings: { foreground: "#171717" },
},
{
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
settings: { foreground: "#d6409f" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#6b7280" },
},
{
scope: [
"support.type.property-name",
"entity.name.tag.json",
"meta.object-literal.key",
],
settings: { foreground: "#171717" },
},
{
scope: ["entity.other.attribute-name"],
settings: { foreground: "#067a6e" },
},
{
scope: ["support.type.primitive", "entity.name.type.primitive"],
settings: { foreground: "#067a6e" },
},
],
};
interface PackageInstallProps {
packages: string;
}
const packageManagers = [
{ label: "npm", value: "npm", command: "npm install" },
{ label: "pnpm", value: "pnpm", command: "pnpm add" },
{ label: "yarn", value: "yarn", command: "yarn add" },
{ label: "bun", value: "bun", command: "bun add" },
];
export async function PackageInstall({ packages }: PackageInstallProps) {
const tabs = await Promise.all(
packageManagers.map(async (pm) => {
const code = `${pm.command} ${packages}`;
const html = await codeToHtml(code, {
lang: "bash",
themes: {
light: vercelLightTheme,
dark: vercelDarkTheme,
},
defaultColor: false,
});
return {
label: pm.label,
value: pm.value,
code,
html,
};
}),
);
return <CodeTabs tabs={tabs} />;
}
+577
View File
@@ -0,0 +1,577 @@
"use client";
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { useUIStream } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { Header } from "./header";
import { PlaygroundRenderer } from "@/lib/renderer";
type Tab = "json" | "stream";
type RenderView = "preview" | "code";
type MobilePane = "chat" | "code" | "preview";
interface Version {
id: string;
prompt: string;
tree: Spec | null;
status: "generating" | "complete" | "error";
}
const EXAMPLE_PROMPTS = [
"Create a login form",
"Build a pricing page",
"Design a user profile card",
"Make a contact form",
];
export function Playground() {
const [versions, setVersions] = useState<Version[]>([]);
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
null,
);
const [inputValue, setInputValue] = useState("");
const [streamLines, setStreamLines] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<Tab>("json");
const [renderView, setRenderView] = useState<RenderView>("preview");
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const inputRef = useRef<HTMLTextAreaElement>(null);
const versionsEndRef = useRef<HTMLDivElement>(null);
// Track the currently generating version ID
const generatingVersionIdRef = useRef<string | null>(null);
// Track the current tree for use as previousSpec in next generation
const currentTreeRef = useRef<Spec | null>(null);
const {
spec: apiSpec,
isStreaming,
send,
clear,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
// Mark the version as errored
if (generatingVersionIdRef.current) {
const erroredVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
),
);
generatingVersionIdRef.current = null;
}
},
} as Parameters<typeof useUIStream>[0]);
// Get the selected version
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
// Determine which tree to display:
// - If streaming and selected version is the generating one, show apiSpec
// - Otherwise show the selected version's tree
const isSelectedVersionGenerating =
selectedVersionId === generatingVersionIdRef.current && isStreaming;
const hasValidApiTree =
apiSpec && apiSpec.root && Object.keys(apiSpec.elements).length > 0;
const currentTree =
isSelectedVersionGenerating && hasValidApiTree
? apiSpec
: (selectedVersion?.tree ??
(isSelectedVersionGenerating ? apiSpec : null));
// Keep the ref updated with the current tree for use in handleSubmit
if (
currentTree &&
currentTree.root &&
Object.keys(currentTree.elements).length > 0
) {
currentTreeRef.current = currentTree;
}
// Scroll to bottom when versions change
useEffect(() => {
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [versions]);
useEffect(() => {
if (apiSpec) {
const streamLine = JSON.stringify({ tree: apiSpec });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiSpec.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine !== streamLine) {
return [...prev, streamLine];
}
return prev;
});
}
}
}, [apiSpec, streamLines]);
// Update version when streaming completes
useEffect(() => {
if (
!isStreaming &&
apiSpec &&
apiSpec.root &&
generatingVersionIdRef.current
) {
const completedVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
prev.map((v) =>
v.id === completedVersionId
? { ...v, tree: apiSpec, status: "complete" as const }
: v,
),
);
generatingVersionIdRef.current = null;
}
}, [isStreaming, apiSpec]);
const handleSubmit = useCallback(async () => {
if (!inputValue.trim() || isStreaming) return;
const newVersionId = Date.now().toString();
const newVersion: Version = {
id: newVersionId,
prompt: inputValue.trim(),
tree: null,
status: "generating",
};
generatingVersionIdRef.current = newVersionId;
setVersions((prev) => [...prev, newVersion]);
setSelectedVersionId(newVersionId);
setInputValue("");
setStreamLines([]); // Reset stream lines for new generation
// Pass the current tree as context so the API can iterate on it
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
}, [inputValue, isStreaming, send]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
const generatedCode = useMemo(() => {
if (!currentTree || !currentTree.root) {
return "// Generate a UI to see the code";
}
const tree = currentTree;
const components = collectUsedComponents(tree);
function generateJSX(key: string, indent: number): string {
const element = tree.elements[key];
if (!element) return "";
const spaces = " ".repeat(indent);
const componentName = element.type;
const propsObj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(element.props)) {
if (v !== null && v !== undefined) {
propsObj[k] = v;
}
}
const propsStr = serializeProps(propsObj);
const hasChildren = element.children && element.children.length > 0;
if (!hasChildren) {
return propsStr
? `${spaces}<${componentName} ${propsStr} />`
: `${spaces}<${componentName} />`;
}
const lines: string[] = [];
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
}
lines.push(`${spaces}</${componentName}>`);
return lines.join("\n");
}
const jsx = generateJSX(tree.root, 2);
const imports = Array.from(components).sort().join(", ");
return `"use client";
import { ${imports} } from "@/components/ui";
export default function Page() {
return (
<div className="min-h-screen p-8 flex items-center justify-center">
${jsx}
</div>
);
}`;
}, [currentTree]);
// Chat pane content
const chatPane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center">
<span className="text-xs font-mono text-muted-foreground">
versions
</span>
</div>
<div
className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
>
{versions.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<p className="text-sm text-muted-foreground mb-4">
Describe what you want to build, then iterate on it.
</p>
<div className="flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => {
setInputValue(prompt);
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setSelectionRange(
prompt.length,
prompt.length,
);
}
}, 0);
}}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
>
{prompt}
</button>
))}
</div>
</div>
) : (
versions.map((version, index) => (
<button
key={version.id}
onClick={() => setSelectedVersionId(version.id)}
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
selectedVersionId === version.id
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}`}
>
<div className="flex items-center gap-2">
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
v{index + 1}
</span>
<span className="truncate flex-1">{version.prompt}</span>
{version.status === "generating" && (
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
...
</span>
)}
{version.status === "error" && (
<span className="text-xs text-red-500 shrink-0">failed</span>
)}
</div>
</button>
))
)}
<div ref={versionsEndRef} />
</div>
<div
className="border-t border-border p-3 cursor-text"
onMouseDown={(e) => {
// Focus textarea unless clicking a button or the textarea itself
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
inputRef.current?.focus();
}
}}
>
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Describe changes..."
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
{versions.length > 0 ? (
<button
onClick={() => {
setVersions([]);
setSelectedVersionId(null);
setStreamLines([]);
clear();
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
) : (
<div />
)}
{isStreaming ? (
<button
onClick={() => clear()}
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"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
>
<rect x="6" y="6" width="12" height="12" />
</svg>
</button>
) : (
<button
onClick={handleSubmit}
disabled={!inputValue.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="Send"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</button>
)}
</div>
</div>
</div>
);
// Code pane content
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", "stream"] 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" />
<CopyButton
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
className="text-muted-foreground"
/>
</div>
<div className="flex-1 overflow-auto">
{activeTab === "stream" ? (
streamLines.length > 0 ? (
<CodeBlock
code={streamLines.join("\n")}
lang="json"
fillHeight
hideCopyButton
/>
) : (
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
{isStreaming ? "streaming..." : "// waiting for generation"}
</div>
)
) : (
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
)}
</div>
</div>
);
// Preview pane content
const previewPane = (
<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">
{(
[
{ key: "preview", label: "preview" },
{ key: "code", label: "code" },
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setRenderView(key)}
className={`text-xs font-mono transition-colors ${
renderView === key
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
<div className="flex-1" />
{renderView === "code" && (
<CopyButton text={generatedCode} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 overflow-auto">
{renderView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
<PlaygroundRenderer spec={currentTree} loading={isStreaming} />
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
{isStreaming
? "generating..."
: "// enter a prompt to generate UI"}
</div>
)
) : (
<CodeBlock
code={generatedCode}
lang="tsx"
fillHeight
hideCopyButton
/>
)}
</div>
</div>
);
return (
<div className="h-full flex flex-col">
<Header />
{/* 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: Single pane with bottom tabs */}
<div className="flex lg:hidden flex-col flex-1 min-h-0">
{/* Panes - all in DOM, visibility controlled */}
<div className="flex-1 min-h-0 relative">
<div
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
>
{chatPane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
>
{codePane}
</div>
<div
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
>
{previewPane}
</div>
</div>
{/* Bottom tab bar */}
<div className="border-t border-border flex shrink-0">
{(
[
{
key: "chat",
label: "Chat",
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
},
{
key: "code",
label: "Code",
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
},
{
key: "preview",
label: "Preview",
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
},
] as const
).map(({ key, label, icon }) => (
<button
key={key}
onClick={() => setMobilePane(key)}
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
mobilePane === key ? "text-foreground" : "text-muted-foreground"
}`}
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
</svg>
<span className="text-xs">{label}</span>
</button>
))}
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+9 -9
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -22,8 +22,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
}
)
},
);
function Badge({
className,
@@ -32,7 +32,7 @@ function Badge({
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
const Comp = asChild ? Slot : "span";
return (
<Comp
@@ -40,7 +40,7 @@ function Badge({
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+10 -10
View File
@@ -1,8 +1,8 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
@@ -33,8 +33,8 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
function Button({
className,
@@ -44,9 +44,9 @@ function Button({
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : "button";
return (
<Comp
@@ -56,7 +56,7 @@ function Button({
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+13 -13
View File
@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
@@ -8,11 +8,11 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
className,
)}
{...props}
/>
)
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
@@ -21,11 +21,11 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
className,
)}
{...props}
/>
)
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
@@ -35,7 +35,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
@@ -45,7 +45,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
@@ -54,11 +54,11 @@ function CardAction({ className, ...props }: React.ComponentProps<"div">) {
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
className,
)}
{...props}
/>
)
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
@@ -68,7 +68,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
className={cn("px-6", className)}
{...props}
/>
)
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -78,7 +78,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
);
}
export {
@@ -89,4 +89,4 @@ export {
CardAction,
CardDescription,
CardContent,
}
};
+32
View File
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };
+24
View File
@@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };
+31
View File
@@ -0,0 +1,31 @@
"use client";
import * as React from "react";
import { Progress as ProgressPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className,
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };
+45
View File
@@ -0,0 +1,45 @@
"use client";
import * as React from "react";
import { CircleIcon } from "lucide-react";
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };
+54
View File
@@ -0,0 +1,54 @@
"use client";
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import { Group, Panel, Separator } from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof Group>) {
return (
<Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
return <Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof Separator> & {
withHandle?: boolean;
}) {
return (
<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 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>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>
)}
</Separator>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className,
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

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