mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 04:48:17 +08:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
801708a128 | ||
|
|
e9ea9c782b | ||
|
|
dd17549ee8 | ||
|
|
1eb6212dd7 | ||
|
|
711e79b069 | ||
|
|
f3611860a7 | ||
|
|
61ee8e5291 | ||
|
|
d38b281e5e | ||
|
|
39fcc192e9 | ||
|
|
96c7a452b3 | ||
|
|
844f18d1ee | ||
|
|
54bce097a5 | ||
|
|
e2cb239c88 | ||
|
|
90c59c674d | ||
|
|
bba38727e8 | ||
|
|
1d547b579c | ||
|
|
795cdaac56 | ||
|
|
4ae049f218 | ||
|
|
f004caf95e | ||
|
|
72d6468e70 | ||
|
|
e6f2b1e9b8 | ||
|
|
85f82590a8 | ||
|
|
6129f312ea | ||
|
|
c1b1aaaddb | ||
|
|
a48f6d665d | ||
|
|
06235798e1 | ||
|
|
e323d263d0 | ||
|
|
75ab55f178 | ||
|
|
43756783fe | ||
|
|
af9dd04085 |
@@ -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.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$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/react-native",
|
||||||
|
"@json-render/remotion",
|
||||||
|
"@json-render/codegen"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"linked": [],
|
||||||
|
"access": "public",
|
||||||
|
"baseBranch": "main",
|
||||||
|
"updateInternalDependencies": "patch",
|
||||||
|
"privatePackages": {
|
||||||
|
"version": false,
|
||||||
|
"tag": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }}
|
||||||
@@ -21,11 +21,15 @@ coverage
|
|||||||
# Vercel
|
# Vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
|
||||||
# Build Outputs
|
# Build Outputs
|
||||||
.next/
|
.next/
|
||||||
out/
|
out/
|
||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
@@ -39,3 +43,4 @@ yarn-error.log*
|
|||||||
|
|
||||||
# opensrc - source code for packages
|
# opensrc - source code for packages
|
||||||
opensrc/
|
opensrc/
|
||||||
|
.env*.local
|
||||||
|
|||||||
@@ -2,9 +2,31 @@
|
|||||||
|
|
||||||
Instructions for AI coding agents working with this codebase.
|
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
|
## Code Style
|
||||||
|
|
||||||
- Do not use emojis in code or UI
|
- 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
|
## Workflow
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
**Predictable. Guardrailed. Fast.**
|
**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
|
```bash
|
||||||
npm install @json-render/core @json-render/react
|
npm install @json-render/core @json-render/react
|
||||||
|
# or for video
|
||||||
|
npm install @json-render/core @json-render/remotion
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why json-render?
|
## Why json-render?
|
||||||
@@ -18,83 +20,75 @@ When users prompt for UI, you need guarantees. json-render gives AI a **constrai
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Define Your Catalog (what AI can use)
|
### 1. Define Your Catalog
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createCatalog } from '@json-render/core';
|
import { defineCatalog } from "@json-render/core";
|
||||||
import { z } from 'zod';
|
import { schema } from "@json-render/react";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
const catalog = createCatalog({
|
const catalog = defineCatalog(schema, {
|
||||||
components: {
|
components: {
|
||||||
Card: {
|
Card: {
|
||||||
props: z.object({ title: z.string() }),
|
props: z.object({ title: z.string() }),
|
||||||
hasChildren: true,
|
description: "A card container",
|
||||||
},
|
},
|
||||||
Metric: {
|
Metric: {
|
||||||
props: z.object({
|
props: z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
valuePath: z.string(), // Binds to your data
|
value: z.string(),
|
||||||
format: z.enum(['currency', 'percent', 'number']),
|
format: z.enum(["currency", "percent", "number"]).nullable(),
|
||||||
}),
|
}),
|
||||||
|
description: "Display a metric value",
|
||||||
},
|
},
|
||||||
Button: {
|
Button: {
|
||||||
props: z.object({
|
props: z.object({
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
action: ActionSchema, // AI declares intent, you handle it
|
action: z.string(),
|
||||||
}),
|
}),
|
||||||
|
description: "Clickable button",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
export_report: { description: 'Export dashboard to PDF' },
|
export_report: { description: "Export dashboard to PDF" },
|
||||||
refresh_data: { description: 'Refresh all metrics' },
|
refresh_data: { description: "Refresh all metrics" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Register Your Components (how they render)
|
### 2. Define Your Components
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const registry = {
|
import { defineRegistry, Renderer } from "@json-render/react";
|
||||||
Card: ({ element, children }) => (
|
|
||||||
<div className="card">
|
const { registry } = defineRegistry(catalog, {
|
||||||
<h3>{element.props.title}</h3>
|
components: {
|
||||||
{children}
|
Card: ({ props, children }) => (
|
||||||
</div>
|
<div className="card">
|
||||||
),
|
<h3>{props.title}</h3>
|
||||||
Metric: ({ element }) => {
|
{children}
|
||||||
const value = useDataValue(element.props.valuePath);
|
</div>
|
||||||
return <div className="metric">{format(value)}</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
|
```tsx
|
||||||
import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
|
function Dashboard({ spec }) {
|
||||||
|
return <Renderer spec={spec} registry={registry} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,11 +96,94 @@ function Dashboard() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
| Package | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||||
|
| `@json-render/react` | React renderer, contexts, hooks |
|
||||||
|
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||||
|
| `@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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -121,66 +198,41 @@ Show/hide components based on data, auth, or complex logic:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
```json
|
### Dynamic Props
|
||||||
{
|
|
||||||
"type": "AdminPanel",
|
|
||||||
"visible": { "auth": "signedIn" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rich Actions
|
Any prop value can be data-driven using expressions:
|
||||||
|
|
||||||
Actions with confirmation dialogs and callbacks:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "Button",
|
"type": "Icon",
|
||||||
"props": {
|
"props": {
|
||||||
"label": "Refund Payment",
|
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
|
||||||
"action": {
|
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||||
"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
|
Two expression forms:
|
||||||
|
|
||||||
|
- **`{ "$path": "/state/key" }`** -- reads a value from the data model
|
||||||
|
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** -- evaluates a condition (same syntax as visibility conditions) and picks a branch
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
Components can trigger actions, including the built-in `setState` action:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "TextField",
|
"type": "Pressable",
|
||||||
"props": {
|
"props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
|
||||||
"label": "Email",
|
"children": ["home-icon"]
|
||||||
"valuePath": "/form/email",
|
|
||||||
"checks": [
|
|
||||||
{ "fn": "required", "message": "Email is required" },
|
|
||||||
{ "fn": "email", "message": "Invalid email" }
|
|
||||||
],
|
|
||||||
"validateOn": "blur"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The `setState` action updates the data model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Packages
|
|
||||||
|
|
||||||
| Package | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `@json-render/core` | Types, schemas, visibility, actions, validation |
|
|
||||||
| `@json-render/react` | React renderer, providers, hooks |
|
|
||||||
|
|
||||||
## Demo
|
## Demo
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -192,32 +244,19 @@ pnpm dev
|
|||||||
|
|
||||||
- http://localhost:3000 — Docs & Playground
|
- http://localhost:3000 — Docs & Playground
|
||||||
- http://localhost:3001 — Example Dashboard
|
- http://localhost:3001 — Example Dashboard
|
||||||
|
- http://localhost:3002 — Remotion Video Example
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
json-render/
|
|
||||||
├── packages/
|
|
||||||
│ ├── core/ → @json-render/core
|
|
||||||
│ └── react/ → @json-render/react
|
|
||||||
├── apps/
|
|
||||||
│ └── web/ → Docs & Playground site
|
|
||||||
└── examples/
|
|
||||||
└── dashboard/ → Example dashboard app
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
|
|
||||||
```
|
```mermaid
|
||||||
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
flowchart LR
|
||||||
│ User Prompt │────▶│ AI + Catalog│────▶│ JSON Tree │
|
A[User Prompt] --> B[AI + Catalog]
|
||||||
│ "dashboard" │ │ (guardrailed)│ │(predictable)│
|
B --> C[JSON Spec]
|
||||||
└─────────────┘ └──────────────┘ └─────────────┘
|
C --> D[Renderer]
|
||||||
│
|
|
||||||
┌──────────────┐ │
|
B -.- E([guardrailed])
|
||||||
│ Your React │◀───────────┘
|
C -.- F([predictable])
|
||||||
│ Components │ (streamed)
|
D -.- G([streamed])
|
||||||
└──────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
1. **Define the guardrails** — what components, actions, and data bindings AI can use
|
1. **Define the guardrails** — what components, actions, and data bindings AI can use
|
||||||
|
|||||||
+12
-2
@@ -1,4 +1,14 @@
|
|||||||
# AI Gateway API Key (required for /api/generate endpoint)
|
# Vercel AI Gateway
|
||||||
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
|
# Automatically authenticated when deployed on Vercel
|
||||||
# For local development, get your key from https://vercel.com/ai-gateway
|
# For local development, get your key from https://vercel.com/ai-gateway
|
||||||
AI_GATEWAY_API_KEY=
|
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=
|
||||||
|
|||||||
@@ -35,3 +35,4 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
.env*.local
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
export const metadata = { title: "A2UI Integration" }
|
||||||
|
|
||||||
|
# A2UI Integration
|
||||||
|
|
||||||
|
Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
## Native A2UI Support
|
||||||
|
|
||||||
|
`@json-render/core` is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed.
|
||||||
|
|
||||||
|
## Example A2UI Message
|
||||||
|
|
||||||
|
A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components:
|
||||||
|
|
||||||
|
```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"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define the A2UI Catalog
|
||||||
|
|
||||||
|
```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...
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define the A2UI Schema
|
||||||
|
|
||||||
|
Define the schema for A2UI message types:
|
||||||
|
|
||||||
|
```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),
|
||||||
|
});
|
||||||
|
|
||||||
|
// State model update message
|
||||||
|
const StateModelUpdate = 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: StateModelUpdate.optional(),
|
||||||
|
beginRendering: BeginRendering.optional(),
|
||||||
|
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build an A2UI Renderer
|
||||||
|
|
||||||
|
Create a renderer that processes the A2UI adjacency list format:
|
||||||
|
|
||||||
|
```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);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```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)}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol.
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
export const metadata = { title: "Adaptive Cards Integration" }
|
||||||
|
|
||||||
|
# Adaptive Cards Integration
|
||||||
|
|
||||||
|
Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
## Adaptive Cards Overview
|
||||||
|
|
||||||
|
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards have a `body` array of elements and an optional `actions` array for interactive buttons.
|
||||||
|
|
||||||
|
### Example Adaptive Card
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Creating an Adaptive Cards Catalog
|
||||||
|
|
||||||
|
Define a catalog matching the Adaptive Cards element types:
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// 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.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.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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building an Adaptive Cards Renderer
|
||||||
|
|
||||||
|
Create a renderer that processes Adaptive Cards JSON. See the [A2UI integration](/docs/a2ui) page for a similar pattern. The key is mapping each Adaptive Card element type to a React component, resolving nested `items` and `columns` arrays recursively.
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
Render an Adaptive Card and handle actions:
|
||||||
|
|
||||||
|
```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} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Handling Action.Execute for Bots
|
||||||
|
|
||||||
|
For bot scenarios, handle `Action.Execute` with the verb and data:
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [A2UI integration](/docs/a2ui) for another agent-driven UI protocol.
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
export const metadata = { title: "AG-UI Integration" }
|
||||||
|
|
||||||
|
# AG-UI Integration
|
||||||
|
|
||||||
|
Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
## What is AG-UI?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## AG-UI Event Types
|
||||||
|
|
||||||
|
AG-UI defines several event types for agent-UI communication:
|
||||||
|
|
||||||
|
- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` — Streaming text messages
|
||||||
|
- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` — Tool/function calls
|
||||||
|
- `STATE_SNAPSHOT` / `STATE_DELTA` — State updates
|
||||||
|
- `CUSTOM` — Custom events for UI rendering
|
||||||
|
|
||||||
|
### Example AG-UI Event Stream
|
||||||
|
|
||||||
|
```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"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define the AG-UI Schema
|
||||||
|
|
||||||
|
Define schemas for AG-UI event types:
|
||||||
|
|
||||||
|
```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>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define the AG-UI Catalog
|
||||||
|
|
||||||
|
Create a catalog for UI components that agents can render:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createCatalog } from '@json-render/core';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const aguiCatalog = createCatalog({
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Text: {
|
||||||
|
description: 'Text content',
|
||||||
|
props: z.object({
|
||||||
|
content: z.string(),
|
||||||
|
variant: z.enum(['body', 'heading', 'caption', 'code']).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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
description: 'Interactive button',
|
||||||
|
props: z.object({
|
||||||
|
label: z.string(),
|
||||||
|
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
|
||||||
|
disabled: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Alert: {
|
||||||
|
description: 'Alert message',
|
||||||
|
props: z.object({
|
||||||
|
message: z.string(),
|
||||||
|
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Add more components...
|
||||||
|
},
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build an AG-UI Event Processor
|
||||||
|
|
||||||
|
Process AG-UI events and render UI components:
|
||||||
|
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { state: aguiState, processEvent };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useAGUI } from './use-agui';
|
||||||
|
import { renderToolCallUI } from './renderer';
|
||||||
|
|
||||||
|
export function AGUIChat() {
|
||||||
|
const { state, processEvent } = useAGUI();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{state.messages.map(msg => (
|
||||||
|
<div key={msg.id} className={`p-3 rounded ${
|
||||||
|
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
|
||||||
|
}`}>
|
||||||
|
{msg.content}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Array.from(state.toolCalls.values()).map((tc, i) => (
|
||||||
|
<div key={i}>{renderToolCallUI(tc)}</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [OpenAPI integration](/docs/openapi) for rendering forms from API schemas.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export const metadata = { title: "AI SDK Integration" }
|
||||||
|
|
||||||
|
# AI SDK Integration
|
||||||
|
|
||||||
|
Use json-render with the Vercel AI SDK for seamless streaming.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install ai
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Route Setup
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/api/generate/route.ts
|
||||||
|
import { streamText } from 'ai';
|
||||||
|
import { catalog } from '@/lib/catalog';
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { prompt, currentTree } = await req.json();
|
||||||
|
|
||||||
|
// Generate system prompt from catalog
|
||||||
|
const systemPrompt = catalog.prompt();
|
||||||
|
|
||||||
|
// Optionally include current UI state for context
|
||||||
|
const contextPrompt = currentTree
|
||||||
|
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: 'anthropic/claude-haiku-4.5',
|
||||||
|
system: systemPrompt + contextPrompt,
|
||||||
|
prompt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toTextStreamResponse();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client-Side Hook
|
||||||
|
|
||||||
|
Use `useUIStream` on the client:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useUIStream, Renderer } from '@json-render/react';
|
||||||
|
|
||||||
|
function GenerativeUI() {
|
||||||
|
const { spec, isStreaming, error, send } = useUIStream({
|
||||||
|
api: '/api/generate',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => send('Create a dashboard with metrics')}
|
||||||
|
disabled={isStreaming}
|
||||||
|
>
|
||||||
|
{isStreaming ? 'Generating...' : 'Generate'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <p className="text-red-500">{error.message}</p>}
|
||||||
|
|
||||||
|
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prompt Engineering
|
||||||
|
|
||||||
|
The `catalog.prompt()` method creates an optimized system prompt that:
|
||||||
|
|
||||||
|
- Lists all available components and their props
|
||||||
|
- Describes available actions
|
||||||
|
- Specifies the expected JSON output format
|
||||||
|
- Includes examples for better generation
|
||||||
|
|
||||||
|
## Custom System Prompts
|
||||||
|
|
||||||
|
Pass custom rules to tailor AI behavior:
|
||||||
|
|
||||||
|
```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"',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [progressive streaming](/docs/streaming).
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
export const metadata = { title: "@json-render/codegen API" }
|
||||||
|
|
||||||
|
# @json-render/codegen
|
||||||
|
|
||||||
|
Utilities for generating code from UI trees.
|
||||||
|
|
||||||
|
## Tree Traversal
|
||||||
|
|
||||||
|
### traverseSpec
|
||||||
|
|
||||||
|
Walk the UI spec depth-first.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function traverseSpec(
|
||||||
|
spec: Spec,
|
||||||
|
visitor: SpecVisitor,
|
||||||
|
startKey?: string
|
||||||
|
): void
|
||||||
|
|
||||||
|
interface SpecVisitor {
|
||||||
|
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### collectUsedComponents
|
||||||
|
|
||||||
|
Get all unique component types used in a spec.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function collectUsedComponents(spec: Spec): Set<string>
|
||||||
|
|
||||||
|
// Example
|
||||||
|
const components = collectUsedComponents(spec);
|
||||||
|
// Set { 'Card', 'Metric', 'Chart' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### collectStatePaths
|
||||||
|
|
||||||
|
Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function collectStatePaths(spec: Spec): Set<string>
|
||||||
|
|
||||||
|
// Example
|
||||||
|
const paths = collectStatePaths(spec);
|
||||||
|
// Set { 'analytics/revenue', 'analytics/customers' }
|
||||||
|
```
|
||||||
|
|
||||||
|
### collectActions
|
||||||
|
|
||||||
|
Get all action names used in the spec.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function collectActions(spec: Spec): Set<string>
|
||||||
|
|
||||||
|
// Example
|
||||||
|
const actions = collectActions(spec);
|
||||||
|
// Set { 'submit_form', 'refresh_data' }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Serialization
|
||||||
|
|
||||||
|
### serializePropValue
|
||||||
|
|
||||||
|
Serialize a single value to a code string.
|
||||||
|
|
||||||
|
```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 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### serializeProps
|
||||||
|
|
||||||
|
Serialize a props object to a JSX attributes string.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function serializeProps(
|
||||||
|
props: Record<string, unknown>,
|
||||||
|
options?: SerializeOptions
|
||||||
|
): string
|
||||||
|
|
||||||
|
// Example
|
||||||
|
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
|
||||||
|
// 'title="Dashboard" columns={3} disabled'
|
||||||
|
```
|
||||||
|
|
||||||
|
### escapeString
|
||||||
|
|
||||||
|
Escape a string for use in code.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function escapeString(
|
||||||
|
str: string,
|
||||||
|
quotes?: 'single' | 'double'
|
||||||
|
): string
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### GeneratedFile
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface GeneratedFile {
|
||||||
|
/** File path relative to project root */
|
||||||
|
path: string;
|
||||||
|
/** File contents */
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### CodeGenerator
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CodeGenerator {
|
||||||
|
/** Generate files from a UI spec */
|
||||||
|
generate(spec: Spec): GeneratedFile[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### SerializeOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SerializeOptions {
|
||||||
|
/** Quote style for strings */
|
||||||
|
quotes?: 'single' | 'double';
|
||||||
|
/** Indent for objects/arrays */
|
||||||
|
indent?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
export const metadata = { title: "@json-render/core API" }
|
||||||
|
|
||||||
|
# @json-render/core
|
||||||
|
|
||||||
|
Core types, schemas, and utilities.
|
||||||
|
|
||||||
|
## defineCatalog
|
||||||
|
|
||||||
|
Creates a type-safe catalog definition with schema validation.
|
||||||
|
|
||||||
|
```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: {...},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### CatalogConfig
|
||||||
|
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catalog Instance
|
||||||
|
|
||||||
|
The returned catalog provides methods for AI prompt generation, validation, and schema export:
|
||||||
|
|
||||||
|
```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)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Catalog Methods
|
||||||
|
|
||||||
|
```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();
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema System
|
||||||
|
|
||||||
|
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 `schema`).
|
||||||
|
|
||||||
|
### schema
|
||||||
|
|
||||||
|
The schema for flat UI element trees. This is exported from @json-render/react.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema } from '@json-render/react';
|
||||||
|
|
||||||
|
// 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### defineSchema
|
||||||
|
|
||||||
|
Create custom schemas for different output formats (e.g., page-based, block-based).
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Schema Builder API
|
||||||
|
|
||||||
|
The schema builder provides these methods:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Zod Schemas
|
||||||
|
|
||||||
|
Pre-built Zod schemas for common json-render types:
|
||||||
|
|
||||||
|
### Dynamic Value Schemas
|
||||||
|
|
||||||
|
```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" }
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Visibility & Logic Schemas
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Schemas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ActionSchema, // Full action definition
|
||||||
|
ActionConfirmSchema, // Confirmation dialog config
|
||||||
|
ActionOnSuccessSchema, // Success handler config
|
||||||
|
ActionOnErrorSchema, // Error handler config
|
||||||
|
} from '@json-render/core';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Schemas
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
ValidationCheckSchema, // Single validation check
|
||||||
|
ValidationConfigSchema, // Full validation config with checks array
|
||||||
|
} from '@json-render/core';
|
||||||
|
```
|
||||||
|
|
||||||
|
## SpecStream
|
||||||
|
|
||||||
|
SpecStream is json-render's streaming format for progressively building specs from JSONL patches.
|
||||||
|
|
||||||
|
### createSpecStreamCompiler
|
||||||
|
|
||||||
|
Create a streaming compiler that incrementally builds a spec:
|
||||||
|
|
||||||
|
```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();
|
||||||
|
```
|
||||||
|
|
||||||
|
### compileSpecStream
|
||||||
|
|
||||||
|
Compile an entire SpecStream string at once:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { compileSpecStream } from '@json-render/core';
|
||||||
|
|
||||||
|
const jsonl = `{"op":"add","path":"/root","value":{}}
|
||||||
|
{"op":"add","path":"/root/type","value":"Card"}`;
|
||||||
|
|
||||||
|
const spec = compileSpecStream<MySpec>(jsonl);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Low-Level Utilities
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
parseSpecStreamLine,
|
||||||
|
applySpecStreamPatch,
|
||||||
|
} from '@json-render/core';
|
||||||
|
|
||||||
|
// Parse a single line
|
||||||
|
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||||
|
|
||||||
|
// Apply patch to object (mutates in place)
|
||||||
|
const obj = {};
|
||||||
|
applySpecStreamPatch(obj, patch);
|
||||||
|
```
|
||||||
|
|
||||||
|
### SpecStream Types
|
||||||
|
|
||||||
|
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SpecStreamLine {
|
||||||
|
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||||
|
path: string;
|
||||||
|
value?: unknown; // Required for add, replace, test
|
||||||
|
from?: string; // Required for move, copy
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpecStreamCompiler<T> {
|
||||||
|
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
||||||
|
getResult(): T;
|
||||||
|
getPatches(): SpecStreamLine[];
|
||||||
|
reset(): void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Utility Functions
|
||||||
|
|
||||||
|
### Path Utilities
|
||||||
|
|
||||||
|
```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');
|
||||||
|
```
|
||||||
|
|
||||||
|
### resolveDynamicValue
|
||||||
|
|
||||||
|
```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"
|
||||||
|
```
|
||||||
|
|
||||||
|
### findFormValue
|
||||||
|
|
||||||
|
```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);
|
||||||
|
```
|
||||||
|
|
||||||
|
## evaluateVisibility
|
||||||
|
|
||||||
|
Evaluates a visibility condition against data and auth state.
|
||||||
|
|
||||||
|
```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] };
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### UIElement
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface UIElement {
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
children?: string[]; // Keys of child elements
|
||||||
|
visible?: VisibilityCondition;
|
||||||
|
validation?: ValidationSchema;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Spec (Element Tree)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Spec {
|
||||||
|
root: string | null; // Key of root element
|
||||||
|
elements: Record<string, UIElement>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||||
|
|
||||||
|
### Action
|
||||||
|
|
||||||
|
```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> };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ValidationSchema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ValidationSchema {
|
||||||
|
checks: ValidationCheck[];
|
||||||
|
validateOn?: 'change' | 'blur' | 'submit';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ValidationCheck {
|
||||||
|
fn: string;
|
||||||
|
args?: Record<string, unknown>;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
export const metadata = { title: "@json-render/react API" }
|
||||||
|
|
||||||
|
# @json-render/react
|
||||||
|
|
||||||
|
React components, providers, and hooks.
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### StateProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider initialState={object}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActionProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ActionProvider handlers={Record<string, ActionHandler>}>
|
||||||
|
{children}
|
||||||
|
</ActionProvider>
|
||||||
|
|
||||||
|
type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### VisibilityProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VisibilityProvider auth={AuthState}>
|
||||||
|
{children}
|
||||||
|
</VisibilityProvider>
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isSignedIn: boolean;
|
||||||
|
roles?: string[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ValidationProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ValidationProvider functions={Record<string, ValidatorFn>}>
|
||||||
|
{children}
|
||||||
|
</ValidationProvider>
|
||||||
|
|
||||||
|
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `onAction`, and `loading` with catalog-inferred types.
|
||||||
|
|
||||||
|
```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} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Renderer
|
||||||
|
spec={Spec} // The UI spec to render
|
||||||
|
registry={Registry} // Component registry (from defineRegistry)
|
||||||
|
loading={boolean} // Optional loading state
|
||||||
|
fallback={Component} // Optional fallback for unknown types
|
||||||
|
/>
|
||||||
|
|
||||||
|
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Props (via defineRegistry)
|
||||||
|
|
||||||
|
```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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
### useUIStream
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
spec, // Spec | null - current UI state
|
||||||
|
isStreaming, // boolean - true while streaming
|
||||||
|
error, // Error | null
|
||||||
|
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
|
||||||
|
clear, // () => void - reset spec and error
|
||||||
|
} = useUIStream({
|
||||||
|
api: string, // API endpoint URL
|
||||||
|
onComplete?: (spec: Spec) => void, // Called when streaming completes
|
||||||
|
onError?: (error: Error) => void, // Called when an error occurs
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useStateStore
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
data, // Record<string, unknown>
|
||||||
|
setState, // (data: object) => void
|
||||||
|
getValue, // (path: string) => unknown
|
||||||
|
setValue, // (path: string, value: unknown) => void
|
||||||
|
} = useStateStore();
|
||||||
|
```
|
||||||
|
|
||||||
|
### useStateValue
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const value = useStateValue(path: string);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useStateBinding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [value, setValue] = useStateBinding(path: string);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useActions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { dispatch } = useActions();
|
||||||
|
// dispatch(actionName: string, params: object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### useAction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const submitForm = useAction('submit_form');
|
||||||
|
// submitForm(params: object)
|
||||||
|
```
|
||||||
|
|
||||||
|
### useIsVisible
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFieldValidation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
value, // unknown
|
||||||
|
setValue, // (value: unknown) => void
|
||||||
|
errors, // string[]
|
||||||
|
validate, // () => Promise<boolean>
|
||||||
|
isValid, // boolean
|
||||||
|
} = useFieldValidation(path: string, checks: ValidationCheck[]);
|
||||||
|
```
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
export const metadata = { title: "@json-render/remotion API" }
|
||||||
|
|
||||||
|
# @json-render/remotion
|
||||||
|
|
||||||
|
Remotion video renderer. Turn JSON timeline specs into video compositions.
|
||||||
|
|
||||||
|
## schema
|
||||||
|
|
||||||
|
The timeline schema for video specs. Use with `defineCatalog` from core.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/remotion';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
transitions: standardTransitionDefinitions,
|
||||||
|
effects: standardEffectDefinitions,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
The main composition component that renders timeline specs. Use with Remotion's Player or in a Remotion project.
|
||||||
|
|
||||||
|
```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
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Components
|
||||||
|
|
||||||
|
Pass custom components to the Renderer:
|
||||||
|
|
||||||
|
```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 }}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Components
|
||||||
|
|
||||||
|
Pre-built video components included in the package:
|
||||||
|
|
||||||
|
```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';
|
||||||
|
```
|
||||||
|
|
||||||
|
### TitleCard Props
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
backgroundColor?: string; // default: "#1a1a1a"
|
||||||
|
textColor?: string; // default: "#ffffff"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TypingText Props
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catalog Definitions
|
||||||
|
|
||||||
|
Pre-built definitions for creating catalogs:
|
||||||
|
|
||||||
|
```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,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks & Utilities
|
||||||
|
|
||||||
|
### useTransition
|
||||||
|
|
||||||
|
Calculate transition styles for a clip based on current frame:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ClipWrapper
|
||||||
|
|
||||||
|
Automatically apply transitions to clip content:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ClipWrapper } from '@json-render/remotion';
|
||||||
|
|
||||||
|
function MyClip({ clip }) {
|
||||||
|
return (
|
||||||
|
<ClipWrapper clip={clip}>
|
||||||
|
<div>My content with automatic transitions</div>
|
||||||
|
</ClipWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
### TimelineSpec
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TimelineSpec {
|
||||||
|
composition: {
|
||||||
|
id: string;
|
||||||
|
fps: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
durationInFrames: number;
|
||||||
|
};
|
||||||
|
tracks: Track[];
|
||||||
|
clips: Clip[];
|
||||||
|
audio: {
|
||||||
|
tracks: AudioTrack[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clip
|
||||||
|
|
||||||
|
```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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### TransitionStyles
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TransitionStyles {
|
||||||
|
opacity: number;
|
||||||
|
transform: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ComponentRegistry
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type ClipComponent = React.ComponentType<{ clip: Clip }>;
|
||||||
|
type ComponentRegistry = Record<string, ClipComponent>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Transitions
|
||||||
|
|
||||||
|
Available transition types:
|
||||||
|
|
||||||
|
- `fade` - Opacity fade in/out
|
||||||
|
- `slideLeft` - Slide from right
|
||||||
|
- `slideRight` - Slide from left
|
||||||
|
- `slideUp` - Slide from bottom
|
||||||
|
- `slideDown` - Slide from top
|
||||||
|
- `zoom` - Scale zoom in/out
|
||||||
|
- `wipe` - Horizontal wipe
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
export const metadata = { title: "Catalog" }
|
||||||
|
|
||||||
|
# Catalog
|
||||||
|
|
||||||
|
The catalog defines what AI can generate. It's your guardrail.
|
||||||
|
|
||||||
|
## What is a Catalog?
|
||||||
|
|
||||||
|
A catalog is a schema that defines:
|
||||||
|
|
||||||
|
- **Components** — UI elements AI can create (with props and optional slots)
|
||||||
|
- **Actions** — Operations AI can trigger
|
||||||
|
- **Functions** — Custom validation or transformation functions
|
||||||
|
|
||||||
|
## Creating a Catalog
|
||||||
|
|
||||||
|
```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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Definition
|
||||||
|
|
||||||
|
Each component in the catalog has:
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered.
|
||||||
|
|
||||||
|
## Generating AI Prompts
|
||||||
|
|
||||||
|
Use the `catalog.prompt()` method to generate a system prompt for AI:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn how to [register components](/docs/registry) in your registry.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
export const metadata = { title: "Changelog" }
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
Notable changes and updates to json-render.
|
||||||
|
|
||||||
|
## v0.4.0
|
||||||
|
|
||||||
|
February 2026
|
||||||
|
|
||||||
|
### New: Custom Schema System
|
||||||
|
|
||||||
|
Create custom output formats with `defineSchema`. Each renderer now defines its own schema, enabling completely different spec formats for different use cases.
|
||||||
|
|
||||||
|
```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,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### New: Component Slots
|
||||||
|
|
||||||
|
Components can now define which slots they accept. Use `["default"]` for regular children, or named slots like `["header", "footer"]` for more complex layouts.
|
||||||
|
|
||||||
|
```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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### New: AI Prompt Generation
|
||||||
|
|
||||||
|
Catalogs now generate AI system prompts automatically with `catalog.prompt()`. The prompt includes all component definitions, props schemas, and action descriptions - ensuring the AI only generates valid specs.
|
||||||
|
|
||||||
|
```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,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### New: @json-render/remotion
|
||||||
|
|
||||||
|
Generate AI-powered videos with Remotion. Define video catalogs, stream timeline specs, and render with the Remotion Player.
|
||||||
|
|
||||||
|
```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}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Includes 10 standard video components (TitleCard, TypingText, SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for custom components.
|
||||||
|
|
||||||
|
### New: SpecStream
|
||||||
|
|
||||||
|
SpecStream is json-render's streaming format for progressively building specs from JSONL patches. The new compiler API makes it easy to process streaming AI responses.
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Improved: Dashboard Example
|
||||||
|
|
||||||
|
The dashboard example is now a full-featured accounting dashboard with:
|
||||||
|
|
||||||
|
- Persistent SQLite database with Drizzle ORM
|
||||||
|
- RESTful API for customers, invoices, expenses, accounts
|
||||||
|
- Draggable widget reordering
|
||||||
|
- AI-powered widget generation with streaming
|
||||||
|
- Real data binding to database records
|
||||||
|
|
||||||
|
### Improved: Documentation
|
||||||
|
|
||||||
|
- Interactive playground for testing specs
|
||||||
|
- New guides: Custom Schema, Streaming, Code Export
|
||||||
|
- Full API reference for all packages
|
||||||
|
- Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- `UITree` type renamed to `Spec`
|
||||||
|
- Schema is now imported from renderer packages (`@json-render/react`) not core
|
||||||
|
- `defineCatalog` now requires a schema as first argument
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.3.0
|
||||||
|
|
||||||
|
January 2026
|
||||||
|
|
||||||
|
Internal release with codegen foundations.
|
||||||
|
|
||||||
|
- Added `@json-render/codegen` package (spec traversal and JSX serialization)
|
||||||
|
- Configurable AI model via environment variables
|
||||||
|
- Documentation improvements and bug fixes
|
||||||
|
|
||||||
|
*Note: Only @json-render/core was published to npm for this release.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.2.0
|
||||||
|
|
||||||
|
January 2026
|
||||||
|
|
||||||
|
Initial public release.
|
||||||
|
|
||||||
|
- Core catalog and spec types
|
||||||
|
- React renderer with contexts for data, actions, visibility
|
||||||
|
- AI prompt generation from catalogs
|
||||||
|
- Basic streaming support
|
||||||
|
- Dashboard example application
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
export const metadata = { title: "Code Export" }
|
||||||
|
|
||||||
|
# Code Export
|
||||||
|
|
||||||
|
Export generated UI as standalone code for your framework.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- Component templates (standalone, no json-render dependencies)
|
||||||
|
- Package.json and project structure
|
||||||
|
- Framework-specific patterns (Next.js, Remix, etc.)
|
||||||
|
- How data is passed to components
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Code export is split into two parts:
|
||||||
|
|
||||||
|
### 1. @json-render/codegen (utilities)
|
||||||
|
|
||||||
|
Framework-agnostic utilities for building code generators:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
traverseSpec, // Walk the UI spec
|
||||||
|
collectUsedComponents, // Get all component types used
|
||||||
|
collectStatePaths, // Get all data binding paths
|
||||||
|
collectActions, // Get all action names
|
||||||
|
serializeProps, // Convert props to JSX string
|
||||||
|
} from '@json-render/codegen';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Your Project (generator)
|
||||||
|
|
||||||
|
Custom code generator specific to your project and framework:
|
||||||
|
|
||||||
|
```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
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Next.js Export
|
||||||
|
|
||||||
|
See the dashboard example for a complete implementation that exports:
|
||||||
|
|
||||||
|
- `package.json` - Dependencies and scripts
|
||||||
|
- `tsconfig.json` - TypeScript config
|
||||||
|
- `next.config.js` - Next.js config
|
||||||
|
- `app/layout.tsx` - Root layout
|
||||||
|
- `app/globals.css` - Global styles
|
||||||
|
- `app/page.tsx` - Generated page with data
|
||||||
|
- `components/ui/*.tsx` - Standalone components
|
||||||
|
|
||||||
|
## Standalone Components
|
||||||
|
|
||||||
|
The exported components are standalone with no json-render dependencies. They receive data as props instead of using hooks:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the Utilities
|
||||||
|
|
||||||
|
### traverseSpec
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { traverseSpec } from '@json-render/codegen';
|
||||||
|
|
||||||
|
traverseSpec(spec, (element, depth, parent) => {
|
||||||
|
console.log(' '.repeat(depth * 2) + element.type);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### collectUsedComponents
|
||||||
|
|
||||||
|
```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],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### serializeProps
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { serializeProps } from '@json-render/codegen';
|
||||||
|
|
||||||
|
const propsStr = serializeProps({
|
||||||
|
title: 'Dashboard',
|
||||||
|
columns: 3,
|
||||||
|
disabled: true,
|
||||||
|
});
|
||||||
|
// 'title="Dashboard" columns={3} disabled'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Try It
|
||||||
|
|
||||||
|
Run the dashboard example and click "Export Project" to see code generation in action:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd examples/dashboard
|
||||||
|
pnpm dev
|
||||||
|
# Open http://localhost:3001
|
||||||
|
# Generate a widget, then click "Export Project"
|
||||||
|
```
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
export const metadata = { title: "Custom Schema & Renderer" }
|
||||||
|
|
||||||
|
# Custom Schema & Renderer
|
||||||
|
|
||||||
|
Build your own schema and renderer with `@json-render/core`.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`@json-render/core` is schema-agnostic. While `@json-render/react` provides a ready-to-use schema and renderer, you can create your own to match any JSON structure - whether it's a domain-specific format, an existing protocol, or something entirely custom.
|
||||||
|
|
||||||
|
## 1. Define Your Schema
|
||||||
|
|
||||||
|
Start by defining the JSON structure your system will use. Here's an example of a simple dashboard schema:
|
||||||
|
|
||||||
|
```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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Create the Catalog
|
||||||
|
|
||||||
|
Define a catalog that describes your components and validates props:
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Define the Root Schema
|
||||||
|
|
||||||
|
Create a schema for the overall document structure:
|
||||||
|
|
||||||
|
```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>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Build the Renderer
|
||||||
|
|
||||||
|
Create a renderer that maps your schema to React components:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Generate LLM Prompts
|
||||||
|
|
||||||
|
Use the catalog to generate system prompts for AI:
|
||||||
|
|
||||||
|
```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',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Validate Specs
|
||||||
|
|
||||||
|
Validate incoming specs against your schema:
|
||||||
|
|
||||||
|
```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 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```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} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
See how to integrate with [A2UI](/docs/a2ui) or [Adaptive Cards](/docs/adaptive-cards) protocols.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
export const metadata = { title: "Data Binding" }
|
||||||
|
|
||||||
|
# Data Binding
|
||||||
|
|
||||||
|
Connect UI components to your application data using JSON Pointer paths.
|
||||||
|
|
||||||
|
## JSON Pointer Paths
|
||||||
|
|
||||||
|
json-render uses JSON Pointer (RFC 6901) for data paths:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Given this data:
|
||||||
|
{
|
||||||
|
"user": {
|
||||||
|
"name": "Alice",
|
||||||
|
"email": "alice@example.com"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"revenue": 125000,
|
||||||
|
"growth": 0.15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// These paths access:
|
||||||
|
"/user/name" -> "Alice"
|
||||||
|
"/metrics/revenue" -> 125000
|
||||||
|
"/metrics/growth" -> 0.15
|
||||||
|
```
|
||||||
|
|
||||||
|
## StateProvider
|
||||||
|
|
||||||
|
Wrap your app with StateProvider to enable data binding:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StateProvider } from '@json-render/react';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const initialState = {
|
||||||
|
user: { name: 'Alice' },
|
||||||
|
form: { email: '', message: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StateProvider initialState={initialState}>
|
||||||
|
{/* Your UI */}
|
||||||
|
</StateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reading Data
|
||||||
|
|
||||||
|
Use `useStateValue` for read-only access:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStateValue } from '@json-render/react';
|
||||||
|
|
||||||
|
function UserGreeting() {
|
||||||
|
const name = useStateValue('/user/name');
|
||||||
|
return <h1>Hello, {name}!</h1>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Two-Way Binding
|
||||||
|
|
||||||
|
Use `useStateBinding` for read-write access:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStateBinding } from '@json-render/react';
|
||||||
|
|
||||||
|
function EmailInput() {
|
||||||
|
const [email, setEmail] = useStateBinding('/form/email');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email || ''}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the State Context
|
||||||
|
|
||||||
|
Access the full state context for advanced use cases:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStateStore } from '@json-render/react';
|
||||||
|
|
||||||
|
function StateDebugger() {
|
||||||
|
const { data, setState, getValue, setValue } = useStateStore();
|
||||||
|
|
||||||
|
// Read any path
|
||||||
|
const revenue = getValue('/metrics/revenue');
|
||||||
|
|
||||||
|
// Write any path
|
||||||
|
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
||||||
|
|
||||||
|
// Replace all state
|
||||||
|
const resetState = () => setState({ user: {}, form: {} });
|
||||||
|
|
||||||
|
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## In JSON UI Trees
|
||||||
|
|
||||||
|
AI can reference data paths in component props:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Metric",
|
||||||
|
"props": {
|
||||||
|
"label": "Total Revenue",
|
||||||
|
"valuePath": "/metrics/revenue",
|
||||||
|
"format": "currency"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [actions](/docs/actions) for user interactions.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export const metadata = { title: "Installation" }
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Install the core package plus your renderer of choice.
|
||||||
|
|
||||||
|
## For React UI
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||||
|
|
||||||
|
## For Remotion Video
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
|
||||||
|
|
||||||
|
## Peer Dependencies
|
||||||
|
|
||||||
|
json-render requires the following peer dependencies:
|
||||||
|
|
||||||
|
- `react` ^19.0.0
|
||||||
|
- `zod` ^4.0.0
|
||||||
|
|
||||||
|
<PackageInstall packages="react zod" />
|
||||||
|
|
||||||
|
## For AI Integration
|
||||||
|
|
||||||
|
To use json-render with AI models, you'll also need the Vercel AI SDK:
|
||||||
|
|
||||||
|
<PackageInstall packages="ai" />
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
||||||
|
import { DocsSidebar } from "@/components/docs-sidebar";
|
||||||
|
import { CopyPageButton } from "@/components/copy-page-button";
|
||||||
|
import { DocsChat } from "@/components/docs-chat";
|
||||||
|
|
||||||
|
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 pb-20">
|
||||||
|
<div className="flex justify-end mb-4">
|
||||||
|
<CopyPageButton />
|
||||||
|
</div>
|
||||||
|
<article>{children}</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DocsChat />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
export const metadata = { title: "OpenAPI Integration" }
|
||||||
|
|
||||||
|
# OpenAPI Integration
|
||||||
|
|
||||||
|
Use json-render to generate dynamic forms and UIs from [OpenAPI/Swagger](https://swagger.io/specification/) schemas.
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
## Why OpenAPI?
|
||||||
|
|
||||||
|
OpenAPI specifications describe your API's endpoints, request bodies, and response schemas. By converting OpenAPI schemas to json-render specs, you can:
|
||||||
|
|
||||||
|
- Automatically generate forms for API endpoints
|
||||||
|
- Display API responses with type-aware rendering
|
||||||
|
- Keep your UI in sync with your API schema
|
||||||
|
- Let AI generate UIs that match your API contracts
|
||||||
|
|
||||||
|
## Example OpenAPI Schema
|
||||||
|
|
||||||
|
A typical OpenAPI schema for a request body:
|
||||||
|
|
||||||
|
```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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Define an OpenAPI-to-UI Catalog
|
||||||
|
|
||||||
|
Create components that map to OpenAPI data types:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createCatalog } from '@json-render/core';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const openapiCatalog = createCatalog({
|
||||||
|
components: {
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
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(),
|
||||||
|
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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ObjectField: {
|
||||||
|
description: 'Nested object group',
|
||||||
|
props: z.object({
|
||||||
|
name: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
collapsible: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
submit: {
|
||||||
|
description: 'Submit form to API endpoint',
|
||||||
|
params: z.object({ operationId: z.string() }),
|
||||||
|
},
|
||||||
|
reset: {
|
||||||
|
description: 'Reset form to defaults',
|
||||||
|
params: z.object({}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Convert OpenAPI Schema to Spec
|
||||||
|
|
||||||
|
Transform OpenAPI schemas into json-render specs by recursively walking the schema properties and mapping each type to the corresponding catalog component. The converter handles nested objects, enums, arrays, and all primitive types.
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
```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} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-generating from OpenAPI Document
|
||||||
|
|
||||||
|
Load and parse an OpenAPI document to generate forms for all operations:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||||
|
import { operationToSpec } from './openapi-to-spec';
|
||||||
|
|
||||||
|
export async function loadOpenAPISpecs(specUrl: string) {
|
||||||
|
const api = await SwaggerParser.dereference(specUrl);
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [streaming](/docs/streaming) for progressive UI rendering.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const metadata = { title: "Introduction" }
|
||||||
|
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
Predictable. Guardrailed. Fast. Let users generate dashboards, widgets, apps, and data visualizations from prompts.
|
||||||
|
|
||||||
|
## What is json-render?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Why json-render?
|
||||||
|
|
||||||
|
### Guardrailed
|
||||||
|
|
||||||
|
AI can only use components in your catalog. No arbitrary code generation.
|
||||||
|
|
||||||
|
### Predictable
|
||||||
|
|
||||||
|
JSON output matches your schema, every time. Actions are declared by name, you control what they do.
|
||||||
|
|
||||||
|
### Fast
|
||||||
|
|
||||||
|
Stream and render progressively as the model responds. No waiting for completion.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
1. Define the guardrails — what components, actions, and data bindings AI can use
|
||||||
|
2. Users prompt — end users describe what they want in natural language
|
||||||
|
3. AI generates JSON — output is always predictable, constrained to your catalog
|
||||||
|
4. Render fast — stream and render progressively as the model responds
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
export const metadata = { title: "Quick Start" }
|
||||||
|
|
||||||
|
# Quick Start
|
||||||
|
|
||||||
|
Get up and running with json-render in 5 minutes.
|
||||||
|
|
||||||
|
## 1. Define your catalog
|
||||||
|
|
||||||
|
Create a catalog that defines what components AI can use:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/catalog.ts
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema } from '@json-render/react';
|
||||||
|
import { 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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Define your components
|
||||||
|
|
||||||
|
Use `defineRegistry` to map catalog types to React components. Each component receives type-safe `props`, `children`, and `onAction`:
|
||||||
|
|
||||||
|
```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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Create an API route
|
||||||
|
|
||||||
|
Set up a streaming API route for AI generation:
|
||||||
|
|
||||||
|
```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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Render the UI
|
||||||
|
|
||||||
|
Use providers and the `Renderer` with your registry to display AI-generated UI:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// app/page.tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Renderer, StateProvider, 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 (
|
||||||
|
<StateProvider initialState={{}}>
|
||||||
|
<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>
|
||||||
|
</StateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next steps
|
||||||
|
|
||||||
|
- Learn about [catalogs](/docs/catalog) in depth
|
||||||
|
- Explore [data binding](/docs/data-binding) for dynamic values
|
||||||
|
- Add [actions](/docs/actions) for interactivity
|
||||||
|
- Implement [conditional visibility](/docs/visibility)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
export const metadata = { title: "Registry" }
|
||||||
|
|
||||||
|
# Registry
|
||||||
|
|
||||||
|
Register React components and action handlers to bring your catalog to life.
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
|
||||||
|
|
||||||
|
```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, setState) => {
|
||||||
|
const res = await fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(params),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
setState((prev) => ({ ...prev, formResult: result }));
|
||||||
|
},
|
||||||
|
|
||||||
|
export_data: async (params) => {
|
||||||
|
const blob = await generateExport(params.format);
|
||||||
|
downloadBlob(blob, `export.${params.format}`);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The returned object contains:
|
||||||
|
|
||||||
|
- `registry` - component registry for `<Renderer />`
|
||||||
|
- `handlers` - factory for ActionProvider-compatible handlers
|
||||||
|
- `executeAction` - imperative action dispatch (for use outside the React tree)
|
||||||
|
|
||||||
|
## Component Props
|
||||||
|
|
||||||
|
Each component in the registry receives a `ComponentContext` object:
|
||||||
|
|
||||||
|
```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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||||
|
|
||||||
|
## Action Handlers
|
||||||
|
|
||||||
|
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||||
|
|
||||||
|
### Defining Actions
|
||||||
|
|
||||||
|
Define available actions in your catalog:
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementing Action Handlers
|
||||||
|
|
||||||
|
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||||
|
actions: {
|
||||||
|
submit_form: async (params, setState) => {
|
||||||
|
const response = await fetch('/api/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ formId: params.formId }),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
setState((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;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using Data Binding
|
||||||
|
|
||||||
|
Use hooks inside your registry components to read and write data:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useStateStore } from '@json-render/react';
|
||||||
|
import { getByPath } from '@json-render/core';
|
||||||
|
|
||||||
|
// Inside defineRegistry components:
|
||||||
|
|
||||||
|
Metric: ({ props }) => {
|
||||||
|
const { data } = useStateStore();
|
||||||
|
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 } = useStateStore();
|
||||||
|
const value = getByPath(data, props.valuePath) as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using the Renderer
|
||||||
|
|
||||||
|
Wire everything together with providers and the `<Renderer />` component:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Renderer,
|
||||||
|
StateProvider,
|
||||||
|
VisibilityProvider,
|
||||||
|
ActionProvider,
|
||||||
|
} from '@json-render/react';
|
||||||
|
import { registry, handlers } from './registry';
|
||||||
|
|
||||||
|
function App({ spec, data, setState }) {
|
||||||
|
const dataRef = useRef(data);
|
||||||
|
const setStateRef = useRef(setState);
|
||||||
|
dataRef.current = data;
|
||||||
|
setStateRef.current = setState;
|
||||||
|
|
||||||
|
const actionHandlers = useMemo(
|
||||||
|
() => handlers(() => setStateRef.current, () => dataRef.current),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StateProvider initialState={data}>
|
||||||
|
<VisibilityProvider>
|
||||||
|
<ActionProvider handlers={actionHandlers}>
|
||||||
|
<Renderer spec={spec} registry={registry} />
|
||||||
|
</ActionProvider>
|
||||||
|
</VisibilityProvider>
|
||||||
|
</StateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [data binding](/docs/data-binding) for dynamic values.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
export const metadata = { title: "Schemas" }
|
||||||
|
|
||||||
|
# Schemas
|
||||||
|
|
||||||
|
Schemas define the structure and validation rules for your UI specs.
|
||||||
|
|
||||||
|
## What is a Schema?
|
||||||
|
|
||||||
|
A schema defines the JSON structure that describes your UI. It includes:
|
||||||
|
|
||||||
|
- **Element structure** — How components are nested and referenced
|
||||||
|
- **Property types** — What props each component accepts
|
||||||
|
- **Data binding syntax** — How to reference dynamic data
|
||||||
|
- **Action format** — How user interactions are defined
|
||||||
|
|
||||||
|
## Schema-Agnostic by Design
|
||||||
|
|
||||||
|
json-render can work with any JSON schema. `@json-render/core` provides the primitives to define catalogs and renderers for any format:
|
||||||
|
|
||||||
|
- **@json-render/react** — The built-in flat element tree schema
|
||||||
|
- **[A2UI](/docs/a2ui)** — Google's Agent-to-User Interaction protocol
|
||||||
|
- **[Adaptive Cards](/docs/adaptive-cards)** — Microsoft's platform-agnostic UI format
|
||||||
|
- **AG-UI** — CopilotKit's Agent User Interaction Protocol
|
||||||
|
- **OpenAPI/Swagger** — API documentation schemas for dynamic forms
|
||||||
|
- **Custom schemas** — Design your own format tailored to your domain
|
||||||
|
|
||||||
|
See the [Custom Schema guide](/docs/custom-schema) to learn how to implement support for any schema.
|
||||||
|
|
||||||
|
## Built-in Schema
|
||||||
|
|
||||||
|
`@json-render/react` uses a flat element tree schema with a root key and elements map:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "card-1",
|
||||||
|
"elements": {
|
||||||
|
"card-1": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "Dashboard" },
|
||||||
|
"children": ["text-1", "button-1"]
|
||||||
|
},
|
||||||
|
"text-1": {
|
||||||
|
"type": "Text",
|
||||||
|
"props": { "content": "Welcome, $data.user.name" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"button-1": {
|
||||||
|
"type": "Button",
|
||||||
|
"props": { "label": "Click me" },
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Components
|
||||||
|
|
||||||
|
### Element Structure
|
||||||
|
|
||||||
|
In the built-in schema, each element in the elements map has this structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface Element {
|
||||||
|
type: string; // Component type from catalog
|
||||||
|
props: Record<string, any>; // Component properties
|
||||||
|
children: string[]; // Array of child element keys
|
||||||
|
visible?: VisibilityRule; // Conditional display
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Binding Syntax
|
||||||
|
|
||||||
|
Reference dynamic data using the `$data` prefix in props:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Text",
|
||||||
|
"props": {
|
||||||
|
"content": "$data.user.name",
|
||||||
|
"count": "$data.items.length"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Action Format
|
||||||
|
|
||||||
|
Actions are defined in the catalog and referenced from components. The renderer handles action execution:
|
||||||
|
|
||||||
|
```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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Schemas
|
||||||
|
|
||||||
|
`@json-render/core` is schema-agnostic. You can define any JSON structure:
|
||||||
|
|
||||||
|
```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(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema vs Catalog
|
||||||
|
|
||||||
|
The schema and catalog work together but serve different purposes:
|
||||||
|
|
||||||
|
- **Schema** — Defines the JSON structure (how elements are organized)
|
||||||
|
- **Catalog** — Defines available components and their props (what can be used)
|
||||||
|
|
||||||
|
The schema is the grammar; the catalog is the vocabulary.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [specs](/docs/specs) — the actual JSON documents that describe your UI.
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
export const metadata = { title: "Specs" }
|
||||||
|
|
||||||
|
# Specs
|
||||||
|
|
||||||
|
A spec is a JSON document that describes your UI.
|
||||||
|
|
||||||
|
## What is a Spec?
|
||||||
|
|
||||||
|
A spec (specification) is the actual JSON that describes a UI. It conforms to a [schema](/docs/schemas) and uses components from a [catalog](/docs/catalog). Specs can be:
|
||||||
|
|
||||||
|
- Generated by AI in real-time
|
||||||
|
- Stored in a database
|
||||||
|
- Streamed progressively from a server
|
||||||
|
- Hand-authored as JSON files
|
||||||
|
|
||||||
|
json-render is schema-agnostic — your specs can follow any JSON structure you choose.
|
||||||
|
|
||||||
|
## Example Specs
|
||||||
|
|
||||||
|
### Simple Spec
|
||||||
|
|
||||||
|
A basic spec using the `@json-render/react` schema. Note the flat structure with a `root` key and `elements` map:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "card-1",
|
||||||
|
"elements": {
|
||||||
|
"card-1": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "Welcome" },
|
||||||
|
"children": ["text-1"]
|
||||||
|
},
|
||||||
|
"text-1": {
|
||||||
|
"type": "Text",
|
||||||
|
"props": { "content": "Hello, $data.user.name!" },
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complex Spec
|
||||||
|
|
||||||
|
A more complex spec with multiple nested elements:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "card-1",
|
||||||
|
"elements": {
|
||||||
|
"card-1": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "User Profile", "padding": "md" },
|
||||||
|
"children": ["row-1", "button-1"]
|
||||||
|
},
|
||||||
|
"row-1": {
|
||||||
|
"type": "Row",
|
||||||
|
"props": { "gap": "md" },
|
||||||
|
"children": ["avatar-1", "stack-1"]
|
||||||
|
},
|
||||||
|
"avatar-1": {
|
||||||
|
"type": "Avatar",
|
||||||
|
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"stack-1": {
|
||||||
|
"type": "Stack",
|
||||||
|
"props": { "gap": "sm" },
|
||||||
|
"children": ["name-text", "email-text"]
|
||||||
|
},
|
||||||
|
"name-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"email-text": {
|
||||||
|
"type": "Text",
|
||||||
|
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"button-1": {
|
||||||
|
"type": "Button",
|
||||||
|
"props": { "label": "Edit Profile" },
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block-Level Spec
|
||||||
|
|
||||||
|
A high-level spec using semantic blocks for page layouts:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "page",
|
||||||
|
"elements": {
|
||||||
|
"page": {
|
||||||
|
"type": "Page",
|
||||||
|
"props": {},
|
||||||
|
"children": ["header", "hero", "features", "footer"]
|
||||||
|
},
|
||||||
|
"header": {
|
||||||
|
"type": "Header",
|
||||||
|
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"hero": {
|
||||||
|
"type": "Hero",
|
||||||
|
"props": {
|
||||||
|
"title": "Build UIs with JSON",
|
||||||
|
"subtitle": "Let AI generate your interfaces",
|
||||||
|
"ctaLabel": "Get Started",
|
||||||
|
"ctaHref": "/docs"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"type": "Features",
|
||||||
|
"props": { "columns": 3 },
|
||||||
|
"children": ["feature-1", "feature-2", "feature-3"]
|
||||||
|
},
|
||||||
|
"feature-1": {
|
||||||
|
"type": "Feature",
|
||||||
|
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"feature-2": {
|
||||||
|
"type": "Feature",
|
||||||
|
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"feature-3": {
|
||||||
|
"type": "Feature",
|
||||||
|
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"type": "Footer",
|
||||||
|
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec Anatomy
|
||||||
|
|
||||||
|
### Root and Elements
|
||||||
|
|
||||||
|
Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "card-1",
|
||||||
|
"elements": {
|
||||||
|
"card-1": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "My Card" },
|
||||||
|
"children": ["text-1"]
|
||||||
|
},
|
||||||
|
"text-1": { ... }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Element Structure
|
||||||
|
|
||||||
|
Each element in the map has a consistent shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ComponentName",
|
||||||
|
"props": { "label": "Hello" },
|
||||||
|
"children": ["child-1", "child-2"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `type` — Component type from your catalog
|
||||||
|
- `props` — Component properties
|
||||||
|
- `children` — Array of child element keys
|
||||||
|
|
||||||
|
### Dynamic Data
|
||||||
|
|
||||||
|
Props can reference data using `$data` paths:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Metric",
|
||||||
|
"props": {
|
||||||
|
"label": "Total Revenue",
|
||||||
|
"value": "$data.metrics.revenue",
|
||||||
|
"change": "$data.metrics.revenueChange"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Visibility
|
||||||
|
|
||||||
|
Control when elements appear using the `visible` property:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Alert",
|
||||||
|
"props": {
|
||||||
|
"message": "You have unsaved changes"
|
||||||
|
},
|
||||||
|
"children": [],
|
||||||
|
"visible": {
|
||||||
|
"path": "$data.form.isDirty",
|
||||||
|
"operator": "eq",
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working with Specs
|
||||||
|
|
||||||
|
### Rendering a Spec
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Renderer } from '@json-render/react';
|
||||||
|
|
||||||
|
function MyApp({ spec, data }) {
|
||||||
|
return (
|
||||||
|
<Renderer
|
||||||
|
spec={spec}
|
||||||
|
data={data}
|
||||||
|
registry={registry}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validating a Spec
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { validate } from '@json-render/core';
|
||||||
|
|
||||||
|
const result = validate(spec, catalog);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
console.error('Invalid spec:', result.errors);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Specs
|
||||||
|
|
||||||
|
Specs can be streamed incrementally for progressive rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useUIStream } from '@json-render/react';
|
||||||
|
|
||||||
|
function GenerativeUI() {
|
||||||
|
const { spec, isStreaming } = useUIStream({
|
||||||
|
api: '/api/generate',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Renderer
|
||||||
|
spec={spec}
|
||||||
|
registry={registry}
|
||||||
|
loading={isStreaming}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec Sources
|
||||||
|
|
||||||
|
Specs can come from various sources:
|
||||||
|
|
||||||
|
- **AI Generation** — LLMs generate specs based on prompts and catalog
|
||||||
|
- **Database** — Store specs as JSON and load dynamically
|
||||||
|
- **API Response** — Server returns specs based on user/context
|
||||||
|
- **Static Files** — Pre-built specs for known UI patterns
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [catalogs](/docs/catalog) — the vocabulary of components available in your specs.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
export const metadata = { title: "Streaming" }
|
||||||
|
|
||||||
|
# Streaming
|
||||||
|
|
||||||
|
Progressively render UI as AI generates it.
|
||||||
|
|
||||||
|
## SpecStream Format
|
||||||
|
|
||||||
|
json-render uses **SpecStream**, a JSONL-based streaming format where each line is a JSON patch operation that progressively builds your spec:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"op":"add","path":"/root","value":"root"}
|
||||||
|
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
|
||||||
|
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
|
||||||
|
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
## useUIStream Hook
|
||||||
|
|
||||||
|
The hook handles parsing and state management:
|
||||||
|
|
||||||
|
```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
|
||||||
|
clear, // Function to reset spec and error
|
||||||
|
} = useUIStream({
|
||||||
|
api: '/api/generate',
|
||||||
|
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||||
|
onError: (error) => {}, // Optional: called when an error occurs
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Patch Operations (RFC 6902)
|
||||||
|
|
||||||
|
SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
|
||||||
|
|
||||||
|
- `add` — Add a value at a path (creates or replaces for objects, inserts for arrays)
|
||||||
|
- `remove` — Remove the value at a path
|
||||||
|
- `replace` — Replace an existing value at a path
|
||||||
|
- `move` — Move a value from one path to another (requires `from`)
|
||||||
|
- `copy` — Copy a value from one path to another (requires `from`)
|
||||||
|
- `test` — Assert that a value at a path equals the given value
|
||||||
|
|
||||||
|
## Path Format
|
||||||
|
|
||||||
|
Paths use a key-based format for elements:
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Side Setup
|
||||||
|
|
||||||
|
Ensure your API route streams properly:
|
||||||
|
|
||||||
|
```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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Progressive Rendering
|
||||||
|
|
||||||
|
The Renderer automatically updates as the spec changes:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function App() {
|
||||||
|
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isStreaming && <LoadingIndicator />}
|
||||||
|
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aborting Streams
|
||||||
|
|
||||||
|
Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function App() {
|
||||||
|
const { isStreaming, send, clear } = useUIStream({
|
||||||
|
api: '/api/generate',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={() => send('Create dashboard')}>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
<button onClick={clear}>Reset</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Low-Level SpecStream API
|
||||||
|
|
||||||
|
For custom streaming implementations, use the SpecStream compiler directly:
|
||||||
|
|
||||||
|
```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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### One-Shot Compilation
|
||||||
|
|
||||||
|
For non-streaming scenarios, compile entire SpecStream at once:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { compileSpecStream } from '@json-render/core';
|
||||||
|
|
||||||
|
const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||||
|
{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
|
||||||
|
|
||||||
|
const spec = compileSpecStream<MySpec>(jsonl);
|
||||||
|
// { root: { type: "Card", props: { title: "Hello" } } }
|
||||||
|
```
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
export const metadata = { title: "Validation" }
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
Validate form inputs with built-in and custom functions.
|
||||||
|
|
||||||
|
## Built-in Validators
|
||||||
|
|
||||||
|
json-render includes common validation functions:
|
||||||
|
|
||||||
|
- `required` — Value must be non-empty
|
||||||
|
- `email` — Valid email format
|
||||||
|
- `minLength` — Minimum string length
|
||||||
|
- `maxLength` — Maximum string length
|
||||||
|
- `pattern` — Match a regex pattern
|
||||||
|
- `min` — Minimum numeric value
|
||||||
|
- `max` — Maximum numeric value
|
||||||
|
|
||||||
|
## Using Validation in JSON
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "TextField",
|
||||||
|
"props": {
|
||||||
|
"label": "Email",
|
||||||
|
"valuePath": "/form/email",
|
||||||
|
"checks": [
|
||||||
|
{ "fn": "required", "message": "Email is required" },
|
||||||
|
{ "fn": "email", "message": "Invalid email format" }
|
||||||
|
],
|
||||||
|
"validateOn": "blur"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation with Parameters
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "TextField",
|
||||||
|
"props": {
|
||||||
|
"label": "Password",
|
||||||
|
"valuePath": "/form/password",
|
||||||
|
"checks": [
|
||||||
|
{ "fn": "required", "message": "Password is required" },
|
||||||
|
{
|
||||||
|
"fn": "minLength",
|
||||||
|
"args": { "length": 8 },
|
||||||
|
"message": "Password must be at least 8 characters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fn": "pattern",
|
||||||
|
"args": { "pattern": "[A-Z]" },
|
||||||
|
"message": "Must contain at least one uppercase letter"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Validation Functions
|
||||||
|
|
||||||
|
Define custom validators in your catalog:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema } from '@json-render/react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: { /* ... */ },
|
||||||
|
functions: {
|
||||||
|
isValidPhone: {
|
||||||
|
description: 'Validates phone number format',
|
||||||
|
},
|
||||||
|
isUniqueEmail: {
|
||||||
|
description: 'Checks if email is not already registered',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Then implement them in your ValidationProvider:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ValidationProvider } from '@json-render/react';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const customValidators = {
|
||||||
|
isValidPhone: (value) => {
|
||||||
|
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
||||||
|
return phoneRegex.test(value);
|
||||||
|
},
|
||||||
|
isUniqueEmail: async (value) => {
|
||||||
|
const response = await fetch(`/api/check-email?email=${value}`);
|
||||||
|
const { available } = await response.json();
|
||||||
|
return available;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ValidationProvider functions={customValidators}>
|
||||||
|
{/* Your UI */}
|
||||||
|
</ValidationProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using in Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useFieldValidation } from '@json-render/react';
|
||||||
|
|
||||||
|
function TextField({ props }) {
|
||||||
|
const { value, setValue, errors, validate } = useFieldValidation(
|
||||||
|
props.valuePath,
|
||||||
|
props.checks
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label>{props.label}</label>
|
||||||
|
<input
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={() => validate()}
|
||||||
|
/>
|
||||||
|
{errors.map((error, i) => (
|
||||||
|
<p key={i} className="text-red-500 text-sm">{error}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Timing
|
||||||
|
|
||||||
|
Control when validation runs with `validateOn`:
|
||||||
|
|
||||||
|
- `change` — Validate on every input change
|
||||||
|
- `blur` — Validate when field loses focus
|
||||||
|
- `submit` — Validate only on form submission
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [AI SDK integration](/docs/ai-sdk).
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
export const metadata = { title: "Visibility" }
|
||||||
|
|
||||||
|
# Visibility
|
||||||
|
|
||||||
|
Conditionally show or hide components based on data, auth, or logic.
|
||||||
|
|
||||||
|
## VisibilityProvider
|
||||||
|
|
||||||
|
Wrap your app with VisibilityProvider to enable conditional rendering:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { VisibilityProvider } from '@json-render/react';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<StateProvider initialState={data}>
|
||||||
|
<VisibilityProvider>
|
||||||
|
{/* Components can now use visibility conditions */}
|
||||||
|
</VisibilityProvider>
|
||||||
|
</StateProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Path-Based Visibility
|
||||||
|
|
||||||
|
Show/hide based on data values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Alert",
|
||||||
|
"props": { "message": "Form has errors" },
|
||||||
|
"visible": { "path": "/form/hasErrors" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visible when /form/hasErrors is truthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth-Based Visibility
|
||||||
|
|
||||||
|
Show/hide based on authentication state:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "AdminPanel",
|
||||||
|
"visible": { "auth": "signedIn" }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options: "signedIn", "signedOut", "admin", etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logic Expressions
|
||||||
|
|
||||||
|
Combine conditions with logic operators:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// AND - all conditions must be true
|
||||||
|
{
|
||||||
|
"type": "SubmitButton",
|
||||||
|
"visible": {
|
||||||
|
"and": [
|
||||||
|
{ "path": "/form/isValid" },
|
||||||
|
{ "path": "/form/hasChanges" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OR - any condition must be true
|
||||||
|
{
|
||||||
|
"type": "HelpText",
|
||||||
|
"visible": {
|
||||||
|
"or": [
|
||||||
|
{ "path": "/user/isNew" },
|
||||||
|
{ "path": "/settings/showHelp" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOT - invert a condition
|
||||||
|
{
|
||||||
|
"type": "WelcomeBanner",
|
||||||
|
"visible": {
|
||||||
|
"not": { "path": "/user/hasSeenWelcome" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparison Operators
|
||||||
|
|
||||||
|
```json
|
||||||
|
// Equal
|
||||||
|
{
|
||||||
|
"visible": {
|
||||||
|
"eq": [{ "path": "/user/role" }, "admin"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Greater than
|
||||||
|
{
|
||||||
|
"visible": {
|
||||||
|
"gt": [{ "path": "/cart/total" }, 100]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available: eq, ne, gt, gte, lt, lte
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Example
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "RefundButton",
|
||||||
|
"props": { "label": "Process Refund" },
|
||||||
|
"visible": {
|
||||||
|
"and": [
|
||||||
|
{ "auth": "signedIn" },
|
||||||
|
{ "eq": [{ "path": "/user/role" }, "support"] },
|
||||||
|
{ "gt": [{ "path": "/order/amount" }, 0] },
|
||||||
|
{ "not": { "path": "/order/isRefunded" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Using in Components
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useIsVisible } from '@json-render/react';
|
||||||
|
|
||||||
|
// 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>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Learn about [form validation](/docs/validation).
|
||||||
@@ -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,11 +10,11 @@ export default function Home() {
|
|||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
<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">
|
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||||
Predictable. Guardrailed. Fast.
|
AI → json-render → UI
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
<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
|
Define a component catalog. Users prompt. AI outputs JSON constrained
|
||||||
from prompts — safely constrained to components you define.
|
to your catalog. Your components render it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Demo />
|
<Demo />
|
||||||
@@ -127,15 +127,16 @@ export const catalog = createCatalog({
|
|||||||
Constrained output that your components render natively.
|
Constrained output that your components render natively.
|
||||||
</p>
|
</p>
|
||||||
<Code lang="json">{`{
|
<Code lang="json">{`{
|
||||||
"key": "dashboard",
|
"root": "dashboard",
|
||||||
"type": "Card",
|
"elements": {
|
||||||
"props": {
|
"dashboard": {
|
||||||
"title": "Revenue Dashboard",
|
"type": "Card",
|
||||||
"description": null
|
"props": {
|
||||||
},
|
"title": "Revenue Dashboard"
|
||||||
"children": [
|
},
|
||||||
{
|
"children": ["revenue"]
|
||||||
"key": "revenue",
|
},
|
||||||
|
"revenue": {
|
||||||
"type": "Metric",
|
"type": "Metric",
|
||||||
"props": {
|
"props": {
|
||||||
"label": "Total Revenue",
|
"label": "Total Revenue",
|
||||||
@@ -143,13 +144,100 @@ export const catalog = createCatalog({
|
|||||||
"format": "currency"
|
"format": "currency"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
}
|
||||||
}`}</Code>
|
}`}</Code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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's prompt.
|
||||||
|
</p>
|
||||||
|
<Code lang="json">{`{
|
||||||
|
"root": "card",
|
||||||
|
"elements": {
|
||||||
|
"card": {
|
||||||
|
"type": "Card",
|
||||||
|
"props": { "title": "Revenue" },
|
||||||
|
"children": ["metric", "chart"]
|
||||||
|
},
|
||||||
|
"metric": {
|
||||||
|
"type": "Metric",
|
||||||
|
"props": {
|
||||||
|
"label": "Total Revenue",
|
||||||
|
"valuePath": "analytics/revenue",
|
||||||
|
"format": "currency"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"chart": {
|
||||||
|
"type": "Chart",
|
||||||
|
"props": {
|
||||||
|
"statePath": "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} statePath="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 */}
|
{/* Features */}
|
||||||
<section className="border-t border-border">
|
<section className="border-t border-border">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-24">
|
<div className="max-w-5xl mx-auto px-6 py-24">
|
||||||
@@ -164,6 +252,10 @@ export const catalog = createCatalog({
|
|||||||
title: "Streaming",
|
title: "Streaming",
|
||||||
desc: "Progressive rendering as JSON streams from the model",
|
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",
|
title: "Data Binding",
|
||||||
desc: "Two-way binding with JSON Pointer paths",
|
desc: "Two-way binding with JSON Pointer paths",
|
||||||
@@ -176,10 +268,6 @@ export const catalog = createCatalog({
|
|||||||
title: "Visibility",
|
title: "Visibility",
|
||||||
desc: "Conditional show/hide based on data or auth",
|
desc: "Conditional show/hide based on data or auth",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Validation",
|
|
||||||
desc: "Built-in and custom validation functions",
|
|
||||||
},
|
|
||||||
].map((feature) => (
|
].map((feature) => (
|
||||||
<div key={feature.title}>
|
<div key={feature.title}>
|
||||||
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||||
|
import type { ModelMessage, UIMessage } from "ai";
|
||||||
|
import { createBashTool } from "bash-tool";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { allDocsPages } from "@/lib/docs-navigation";
|
||||||
|
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render, a library for AI-generated UI with guardrails.
|
||||||
|
|
||||||
|
GitHub repository: https://github.com/vercel-labs/json-render
|
||||||
|
Documentation: https://json-render.dev/docs
|
||||||
|
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||||
|
|
||||||
|
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
|
||||||
|
|
||||||
|
When answering questions:
|
||||||
|
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
|
||||||
|
- Use the readFile tool to read specific documentation pages
|
||||||
|
- Always base your answers on the actual documentation content
|
||||||
|
- Be concise and accurate
|
||||||
|
- If the docs don't cover a topic, say so honestly
|
||||||
|
- Do NOT include source references or file paths in your response`;
|
||||||
|
|
||||||
|
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||||
|
const files: Record<string, string> = {};
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
allDocsPages.map(async (page) => {
|
||||||
|
const slug =
|
||||||
|
page.href === "/docs" ? "" : page.href.replace(/^\/docs\/?/, "");
|
||||||
|
const filePath = slug
|
||||||
|
? join(
|
||||||
|
process.cwd(),
|
||||||
|
"app",
|
||||||
|
"(main)",
|
||||||
|
"docs",
|
||||||
|
...slug.split("/"),
|
||||||
|
"page.mdx",
|
||||||
|
)
|
||||||
|
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");
|
||||||
|
|
||||||
|
const raw = await readFile(filePath, "utf-8");
|
||||||
|
const md = mdxToCleanMarkdown(raw);
|
||||||
|
const fileName = slug ? `/docs/${slug}.md` : "/docs/index.md";
|
||||||
|
return { fileName, md };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
files[result.value.fileName] = result.value.md;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
|
||||||
|
if (messages.length === 0) return messages;
|
||||||
|
return messages.map((message, index) => {
|
||||||
|
if (index === messages.length - 1) {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
providerOptions: {
|
||||||
|
...message.providerOptions,
|
||||||
|
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const headersList = await headers();
|
||||||
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
|
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 { messages }: { messages: UIMessage[] } = await req.json();
|
||||||
|
|
||||||
|
const docsFiles = await loadDocsFiles();
|
||||||
|
const { tools } = await createBashTool({ files: docsFiles });
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: DEFAULT_MODEL,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: await convertToModelMessages(messages),
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
tools,
|
||||||
|
prepareStep: ({ messages: stepMessages }) => ({
|
||||||
|
messages: addCacheControl(stepMessages),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toUIMessageStreamResponse();
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import { join } from "path";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const docPath = searchParams.get("path");
|
||||||
|
|
||||||
|
if (!docPath) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing ?path= parameter" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize path: only allow docs paths, no traversal
|
||||||
|
const normalized = docPath
|
||||||
|
.replace(/^\//, "")
|
||||||
|
.replace(/\.\./g, "")
|
||||||
|
.replace(/[^a-zA-Z0-9/-]/g, "");
|
||||||
|
|
||||||
|
if (!normalized.startsWith("docs")) {
|
||||||
|
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map URL path to file path
|
||||||
|
// /docs -> /app/(main)/docs/page.mdx
|
||||||
|
// /docs/installation -> /app/(main)/docs/installation/page.mdx
|
||||||
|
const slug = normalized === "docs" ? "" : normalized.replace(/^docs\/?/, "");
|
||||||
|
const filePath = slug
|
||||||
|
? join(
|
||||||
|
process.cwd(),
|
||||||
|
"app",
|
||||||
|
"(main)",
|
||||||
|
"docs",
|
||||||
|
...slug.split("/"),
|
||||||
|
"page.mdx",
|
||||||
|
)
|
||||||
|
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, "utf-8");
|
||||||
|
const markdown = mdxToCleanMarkdown(raw);
|
||||||
|
|
||||||
|
return new NextResponse(markdown, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/markdown; charset=utf-8",
|
||||||
|
"Cache-Control": "public, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +1,94 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { buildUserPrompt } from "@json-render/core";
|
||||||
|
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
|
const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
||||||
|
customRules: [
|
||||||
|
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||||
|
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
|
||||||
|
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
|
||||||
|
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||||
|
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||||
|
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
AVAILABLE COMPONENTS (22):
|
const MAX_PROMPT_LENGTH = 500;
|
||||||
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
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;
|
|
||||||
|
|
||||||
export async function POST(req: Request) {
|
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 userPrompt = buildUserPrompt({
|
||||||
|
prompt,
|
||||||
|
currentSpec: context?.previousSpec,
|
||||||
|
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||||
|
});
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: "anthropic/claude-opus-4.5",
|
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||||
system: SYSTEM_PROMPT,
|
system: SYSTEM_PROMPT,
|
||||||
prompt: sanitizedPrompt,
|
prompt: userPrompt,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.toTextStreamResponse();
|
// Stream the text, then append token usage metadata at the end
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const textStream = result.textStream;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
for await (const chunk of textStream) {
|
||||||
|
controller.enqueue(encoder.encode(chunk));
|
||||||
|
}
|
||||||
|
// Append usage metadata after stream completes
|
||||||
|
try {
|
||||||
|
const usage = await result.usage;
|
||||||
|
const meta = JSON.stringify({
|
||||||
|
__meta: "usage",
|
||||||
|
promptTokens: usage.inputTokens,
|
||||||
|
completionTokens: usage.outputTokens,
|
||||||
|
totalTokens: usage.totalTokens,
|
||||||
|
});
|
||||||
|
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||||
|
} catch {
|
||||||
|
// Usage not available -- skip silently
|
||||||
|
}
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,163 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Code } from "@/components/code";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "AI SDK Integration | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AiSdkPage() {
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">AI SDK Integration</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
Use json-render with the Vercel AI SDK for seamless streaming.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Installation</h2>
|
|
||||||
<Code lang="bash">npm install ai</Code>
|
|
||||||
|
|
||||||
<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);
|
|
||||||
|
|
||||||
// Optionally include current UI state for context
|
|
||||||
const contextPrompt = currentTree
|
|
||||||
? \`\\n\\nCurrent UI state:\\n\${JSON.stringify(currentTree, null, 2)}\`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const result = streamText({
|
|
||||||
model: 'anthropic/claude-opus-4.5',
|
|
||||||
system: systemPrompt + contextPrompt,
|
|
||||||
prompt,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(result.textStream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
|
||||||
'Transfer-Encoding': 'chunked',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Use <code className="text-foreground">useUIStream</code> on the client:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`'use client';
|
|
||||||
|
|
||||||
import { useUIStream } from '@json-render/react';
|
|
||||||
|
|
||||||
function GenerativeUI() {
|
|
||||||
const { tree, isLoading, error, generate } = useUIStream({
|
|
||||||
endpoint: '/api/generate',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
onClick={() => generate('Create a dashboard with metrics')}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
{isLoading ? 'Generating...' : 'Generate'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{error && <p className="text-red-500">{error.message}</p>}
|
|
||||||
|
|
||||||
<Renderer tree={tree} registry={registry} />
|
|
||||||
</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:
|
|
||||||
</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>
|
|
||||||
<li>Describes available actions</li>
|
|
||||||
<li>Specifies the expected JSON output format</li>
|
|
||||||
<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">Next</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Learn about{" "}
|
|
||||||
<Link
|
|
||||||
href="/docs/streaming"
|
|
||||||
className="text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
progressive streaming
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,112 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import { Code } from "@/components/code";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "@json-render/react API | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReactApiPage() {
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">@json-render/react</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
React components, providers, and hooks.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Providers</h2>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">DataProvider</h3>
|
|
||||||
<Code lang="tsx">{`<DataProvider initialData={object}>
|
|
||||||
{children}
|
|
||||||
</DataProvider>`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">ActionProvider</h3>
|
|
||||||
<Code lang="tsx">{`<ActionProvider handlers={Record<string, ActionHandler>}>
|
|
||||||
{children}
|
|
||||||
</ActionProvider>
|
|
||||||
|
|
||||||
type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">VisibilityProvider</h3>
|
|
||||||
<Code lang="tsx">{`<VisibilityProvider auth={AuthState}>
|
|
||||||
{children}
|
|
||||||
</VisibilityProvider>
|
|
||||||
|
|
||||||
interface AuthState {
|
|
||||||
isSignedIn: boolean;
|
|
||||||
roles?: string[];
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationProvider</h3>
|
|
||||||
<Code lang="tsx">{`<ValidationProvider functions={Record<string, ValidatorFn>}>
|
|
||||||
{children}
|
|
||||||
</ValidationProvider>
|
|
||||||
|
|
||||||
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
|
|
||||||
|
|
||||||
interface ComponentProps {
|
|
||||||
element: UIElement;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
onAction: (name: string, params: object) => void;
|
|
||||||
}`}</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
|
|
||||||
} = useUIStream({
|
|
||||||
endpoint: string,
|
|
||||||
});`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
|
|
||||||
<Code lang="typescript">{`const {
|
|
||||||
data, // Record<string, unknown>
|
|
||||||
setData, // (data: object) => void
|
|
||||||
getValue, // (path: string) => unknown
|
|
||||||
setValue, // (path: string, value: unknown) => void
|
|
||||||
} = useData();`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useDataValue</h3>
|
|
||||||
<Code lang="typescript">{`const value = useDataValue(path: string);`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useDataBinding</h3>
|
|
||||||
<Code lang="typescript">{`const [value, setValue] = useDataBinding(path: string);`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useActions</h3>
|
|
||||||
<Code lang="typescript">{`const { dispatch } = useActions();
|
|
||||||
// dispatch(actionName: string, params: object)`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useAction</h3>
|
|
||||||
<Code lang="typescript">{`const submitForm = useAction('submit_form');
|
|
||||||
// submitForm(params: object)`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useIsVisible</h3>
|
|
||||||
<Code lang="typescript">{`const isVisible = useIsVisible(condition?: VisibilityCondition);`}</Code>
|
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">useFieldValidation</h3>
|
|
||||||
<Code lang="typescript">{`const {
|
|
||||||
value, // unknown
|
|
||||||
setValue, // (value: unknown) => void
|
|
||||||
errors, // string[]
|
|
||||||
validate, // () => Promise<boolean>
|
|
||||||
isValid, // boolean
|
|
||||||
} = useFieldValidation(path: string, checks: ValidationCheck[]);`}</Code>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,120 +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'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,111 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Code } from "@/components/code";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Data Binding | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DataBindingPage() {
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">Data Binding</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
Connect UI components to your application data using JSON Pointer paths.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">JSON Pointer Paths</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
json-render uses JSON Pointer (RFC 6901) for data paths:
|
|
||||||
</p>
|
|
||||||
<Code lang="json">{`// Given this data:
|
|
||||||
{
|
|
||||||
"user": {
|
|
||||||
"name": "Alice",
|
|
||||||
"email": "alice@example.com"
|
|
||||||
},
|
|
||||||
"metrics": {
|
|
||||||
"revenue": 125000,
|
|
||||||
"growth": 0.15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// These paths access:
|
|
||||||
"/user/name" -> "Alice"
|
|
||||||
"/metrics/revenue" -> 125000
|
|
||||||
"/metrics/growth" -> 0.15`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">DataProvider</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Wrap your app with DataProvider to enable data binding:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`import { DataProvider } from '@json-render/react';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const initialData = {
|
|
||||||
user: { name: 'Alice' },
|
|
||||||
form: { email: '', message: '' },
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataProvider initialData={initialData}>
|
|
||||||
{/* Your UI */}
|
|
||||||
</DataProvider>
|
|
||||||
);
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`import { useDataValue } from '@json-render/react';
|
|
||||||
|
|
||||||
function UserGreeting() {
|
|
||||||
const name = useDataValue('/user/name');
|
|
||||||
return <h1>Hello, {name}!</h1>;
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`import { useDataBinding } from '@json-render/react';
|
|
||||||
|
|
||||||
function EmailInput() {
|
|
||||||
const [email, setEmail] = useDataBinding('/form/email');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={email || ''}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<Code lang="tsx">{`import { useData } from '@json-render/react';
|
|
||||||
|
|
||||||
function DataDebugger() {
|
|
||||||
const { data, setData, getValue, setValue } = useData();
|
|
||||||
|
|
||||||
// Read any path
|
|
||||||
const revenue = getValue('/metrics/revenue');
|
|
||||||
|
|
||||||
// Write any path
|
|
||||||
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
|
||||||
|
|
||||||
// Replace all data
|
|
||||||
const resetData = () => setData({ user: {}, form: {} });
|
|
||||||
|
|
||||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">In JSON UI Trees</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
AI can reference data paths in component props:
|
|
||||||
</p>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "Metric",
|
|
||||||
"props": {
|
|
||||||
"label": "Total Revenue",
|
|
||||||
"valuePath": "/metrics/revenue",
|
|
||||||
"format": "currency"
|
|
||||||
}
|
|
||||||
}`}</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/actions" className="text-foreground hover:underline">
|
|
||||||
actions
|
|
||||||
</Link>{" "}
|
|
||||||
for user interactions.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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 and React packages to get started.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
|
|
||||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
|
||||||
|
|
||||||
<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'll also need the Vercel AI
|
|
||||||
SDK:
|
|
||||||
</p>
|
|
||||||
<PackageInstall packages="ai" />
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { DocsMobileNav } from "@/components/docs-mobile-nav";
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<>
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
export const metadata = {
|
|
||||||
title: "Introduction | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function DocsPage() {
|
|
||||||
return (
|
|
||||||
<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.
|
|
||||||
</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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
|
|
||||||
<div className="space-y-4 mb-8">
|
|
||||||
<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.
|
|
||||||
</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.
|
|
||||||
</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.
|
|
||||||
</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>
|
|
||||||
</ol>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
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 { 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>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,133 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Code } from "@/components/code";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Validation | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ValidationPage() {
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">Validation</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
Validate form inputs with built-in and custom functions.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Validators</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
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>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
|
||||||
Using Validation in JSON
|
|
||||||
</h2>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "TextField",
|
|
||||||
"props": {
|
|
||||||
"label": "Email",
|
|
||||||
"valuePath": "/form/email",
|
|
||||||
"checks": [
|
|
||||||
{ "fn": "required", "message": "Email is required" },
|
|
||||||
{ "fn": "email", "message": "Invalid email format" }
|
|
||||||
],
|
|
||||||
"validateOn": "blur"
|
|
||||||
}
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
|
||||||
Validation with Parameters
|
|
||||||
</h2>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "TextField",
|
|
||||||
"props": {
|
|
||||||
"label": "Password",
|
|
||||||
"valuePath": "/form/password",
|
|
||||||
"checks": [
|
|
||||||
{ "fn": "required", "message": "Password is required" },
|
|
||||||
{
|
|
||||||
"fn": "minLength",
|
|
||||||
"args": { "length": 8 },
|
|
||||||
"message": "Password must be at least 8 characters"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fn": "pattern",
|
|
||||||
"args": { "pattern": "[A-Z]" },
|
|
||||||
"message": "Must contain at least one uppercase letter"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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({
|
|
||||||
components: { /* ... */ },
|
|
||||||
validationFunctions: {
|
|
||||||
isValidPhone: {
|
|
||||||
description: 'Validates phone number format',
|
|
||||||
},
|
|
||||||
isUniqueEmail: {
|
|
||||||
description: 'Checks if email is not already registered',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});`}</Code>
|
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Then implement them in your ValidationProvider:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`import { ValidationProvider } from '@json-render/react';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const customValidators = {
|
|
||||||
isValidPhone: (value) => {
|
|
||||||
const phoneRegex = /^\\+?[1-9]\\d{1,14}$/;
|
|
||||||
return phoneRegex.test(value);
|
|
||||||
},
|
|
||||||
isUniqueEmail: async (value) => {
|
|
||||||
const response = await fetch(\`/api/check-email?email=\${value}\`);
|
|
||||||
const { available } = await response.json();
|
|
||||||
return available;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ValidationProvider functions={customValidators}>
|
|
||||||
{/* Your UI */}
|
|
||||||
</ValidationProvider>
|
|
||||||
);
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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 }) {
|
|
||||||
const { value, setValue, errors, validate } = useFieldValidation(
|
|
||||||
element.props.valuePath,
|
|
||||||
element.props.checks
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label>{element.props.label}</label>
|
|
||||||
<input
|
|
||||||
value={value || ''}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onBlur={() => validate()}
|
|
||||||
/>
|
|
||||||
{errors.map((error, i) => (
|
|
||||||
<p key={i} className="text-red-500 text-sm">{error}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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>:
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { Code } from "@/components/code";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "Visibility | json-render",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function VisibilityPage() {
|
|
||||||
return (
|
|
||||||
<article>
|
|
||||||
<h1 className="text-3xl font-bold mb-4">Visibility</h1>
|
|
||||||
<p className="text-muted-foreground mb-8">
|
|
||||||
Conditionally show or hide components based on data, auth, or logic.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">VisibilityProvider</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Wrap your app with VisibilityProvider to enable conditional rendering:
|
|
||||||
</p>
|
|
||||||
<Code lang="tsx">{`import { VisibilityProvider } from '@json-render/react';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<DataProvider initialData={data}>
|
|
||||||
<VisibilityProvider>
|
|
||||||
{/* Components can now use visibility conditions */}
|
|
||||||
</VisibilityProvider>
|
|
||||||
</DataProvider>
|
|
||||||
);
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "Alert",
|
|
||||||
"props": { "message": "Form has errors" },
|
|
||||||
"visible": { "path": "/form/hasErrors" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visible when /form/hasErrors is truthy`}</Code>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "AdminPanel",
|
|
||||||
"visible": { "auth": "signedIn" }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Options: "signedIn", "signedOut", "admin", etc.`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Logic Expressions</h2>
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
Combine conditions with logic operators:
|
|
||||||
</p>
|
|
||||||
<Code lang="json">{`// AND - all conditions must be true
|
|
||||||
{
|
|
||||||
"type": "SubmitButton",
|
|
||||||
"visible": {
|
|
||||||
"and": [
|
|
||||||
{ "path": "/form/isValid" },
|
|
||||||
{ "path": "/form/hasChanges" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// OR - any condition must be true
|
|
||||||
{
|
|
||||||
"type": "HelpText",
|
|
||||||
"visible": {
|
|
||||||
"or": [
|
|
||||||
{ "path": "/user/isNew" },
|
|
||||||
{ "path": "/settings/showHelp" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOT - invert a condition
|
|
||||||
{
|
|
||||||
"type": "WelcomeBanner",
|
|
||||||
"visible": {
|
|
||||||
"not": { "path": "/user/hasSeenWelcome" }
|
|
||||||
}
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Comparison Operators</h2>
|
|
||||||
<Code lang="json">{`// Equal
|
|
||||||
{
|
|
||||||
"visible": {
|
|
||||||
"eq": [{ "path": "/user/role" }, "admin"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greater than
|
|
||||||
{
|
|
||||||
"visible": {
|
|
||||||
"gt": [{ "path": "/cart/total" }, 100]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Available: eq, ne, gt, gte, lt, lte`}</Code>
|
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Complex Example</h2>
|
|
||||||
<Code lang="json">{`{
|
|
||||||
"type": "RefundButton",
|
|
||||||
"props": { "label": "Process Refund" },
|
|
||||||
"visible": {
|
|
||||||
"and": [
|
|
||||||
{ "auth": "signedIn" },
|
|
||||||
{ "eq": [{ "path": "/user/role" }, "support"] },
|
|
||||||
{ "gt": [{ "path": "/order/amount" }, 0] },
|
|
||||||
{ "not": { "path": "/order/isRefunded" } }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}`}</Code>
|
|
||||||
|
|
||||||
<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);
|
|
||||||
|
|
||||||
if (!isVisible) return null;
|
|
||||||
return <div>{children}</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/validation"
|
|
||||||
className="text-foreground hover:underline"
|
|
||||||
>
|
|
||||||
form validation
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
|
@source "../node_modules/streamdown/dist/index.js";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
|
--ds-gray-500: oklch(0.836 0 0);
|
||||||
/* Monochrome light theme */
|
/* Monochrome light theme */
|
||||||
--background: oklch(1.0 0 0);
|
--background: oklch(1.0 0 0);
|
||||||
--foreground: oklch(0.1 0 0);
|
--foreground: oklch(0.1 0 0);
|
||||||
@@ -28,6 +31,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
|
--ds-gray-500: oklch(0.39 0 0);
|
||||||
/* Monochrome dark theme */
|
/* Monochrome dark theme */
|
||||||
--background: oklch(0.0 0 0);
|
--background: oklch(0.0 0 0);
|
||||||
--foreground: oklch(0.98 0 0);
|
--foreground: oklch(0.98 0 0);
|
||||||
|
|||||||
+3
-10
@@ -1,11 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Header } from "@/components/header";
|
|
||||||
import { Footer } from "@/components/footer";
|
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { Analytics } from "@vercel/analytics/next";
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||||
|
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||||
|
|
||||||
const geistSans = localFont({
|
const geistSans = localFont({
|
||||||
src: "./fonts/GeistVF.woff",
|
src: "./fonts/GeistVF.woff",
|
||||||
@@ -19,7 +18,7 @@ const geistMono = localFont({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
metadataBase: new URL("https://json-render.dev"),
|
metadataBase: new URL("https://json-render.dev"),
|
||||||
title: {
|
title: {
|
||||||
default: "json-render | AI-generated UI with guardrails",
|
default: `json-render | ${PAGE_TITLES[""]}`,
|
||||||
template: "%s | json-render",
|
template: "%s | json-render",
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
@@ -76,13 +75,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
<div className="min-h-screen flex flex-col">
|
|
||||||
<Header />
|
|
||||||
<main className="flex-1">{children}</main>
|
|
||||||
<Footer />
|
|
||||||
</div>
|
|
||||||
</ThemeProvider>
|
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Header } from "@/components/header";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col">
|
||||||
|
<Header />
|
||||||
|
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
|
||||||
|
<h1 className="text-6xl font-bold tracking-tight">404</h1>
|
||||||
|
<p className="text-lg text-muted-foreground">
|
||||||
|
This page could not be found.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-2 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||||
|
>
|
||||||
|
Go home
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { getPageTitle, renderOgImage } from "../og-image";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ slug: string[] }> },
|
||||||
|
) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const title = getPageTitle(slug.join("/"));
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderOgImage(title);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
export { getPageTitle } from "@/lib/page-titles";
|
||||||
|
|
||||||
|
// Cache font data in memory after first load
|
||||||
|
let fontCache: { geistRegular: Buffer } | null = null;
|
||||||
|
|
||||||
|
async function loadFonts() {
|
||||||
|
if (fontCache) return fontCache;
|
||||||
|
const geistRegular = await readFile(
|
||||||
|
join(process.cwd(), "public/Geist-Regular.ttf"),
|
||||||
|
);
|
||||||
|
fontCache = { geistRegular };
|
||||||
|
return fontCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderOgImage(title: string) {
|
||||||
|
const { geistRegular } = await loadFonts();
|
||||||
|
|
||||||
|
return new ImageResponse(
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
backgroundColor: "black",
|
||||||
|
padding: "60px 80px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "16px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="36" height="36" viewBox="0 0 16 16" fill="white">
|
||||||
|
<path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
|
||||||
|
</svg>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
color: "#666",
|
||||||
|
fontFamily: "Geist",
|
||||||
|
fontWeight: 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 36,
|
||||||
|
fontFamily: "Geist",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "white",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
json-render
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 72,
|
||||||
|
fontFamily: "Geist",
|
||||||
|
fontWeight: 400,
|
||||||
|
color: "white",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
fonts: [
|
||||||
|
{
|
||||||
|
name: "Geist",
|
||||||
|
data: geistRegular.buffer as ArrayBuffer,
|
||||||
|
style: "normal",
|
||||||
|
weight: 400,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,44 +1,6 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { getPageTitle, renderOgImage } from "./og-image";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET() {
|
||||||
const geist = await fetch(new URL("/Geist-Regular.ttf", request.url)).then(
|
const title = getPageTitle("")!;
|
||||||
(res) => res.arrayBuffer(),
|
return renderOgImage(title);
|
||||||
);
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default function PlaygroundLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
|
||||||
|
}
|
||||||
@@ -1,72 +1,11 @@
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Playground } from "@/components/playground";
|
||||||
|
|
||||||
|
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Playground | json-render",
|
title: PAGE_TITLES["playground"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
export default function PlaygroundPage() {
|
||||||
return (
|
return <Playground />;
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,9 +159,16 @@ if (typeof window !== "undefined") {
|
|||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang: "json" | "tsx" | "typescript";
|
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>("");
|
const [html, setHtml] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,19 +187,21 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
|
|||||||
}, [code, lang]);
|
}, [code, lang]);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return fillHeight ? <div className="p-3" /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className={`relative group ${fillHeight ? "p-3" : ""}`}>
|
||||||
<div className="sticky top-0 float-right z-10">
|
{!hideCopyButton && (
|
||||||
<CopyButton
|
<div className="float-right sticky top-3 z-10 ml-2">
|
||||||
text={code}
|
<CopyButton
|
||||||
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
text={code}
|
||||||
/>
|
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<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 }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { codeToHtml } from "shiki";
|
import { codeToHtml } from "shiki";
|
||||||
import { CopyButton } from "./copy-button";
|
import { CopyButton } from "./copy-button";
|
||||||
|
import { ExpandableCode } from "./expandable-code";
|
||||||
|
|
||||||
const vercelDarkTheme = {
|
const vercelDarkTheme = {
|
||||||
name: "vercel-dark",
|
name: "vercel-dark",
|
||||||
@@ -158,10 +159,12 @@ export async function Code({ children, lang = "typescript" }: CodeProps) {
|
|||||||
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
|
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<ExpandableCode>
|
||||||
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
|
<div
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
|
||||||
/>
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
</ExpandableCode>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function CopyPageButton() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
setState("loading");
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch markdown");
|
||||||
|
}
|
||||||
|
const markdown = await response.text();
|
||||||
|
await navigator.clipboard.writeText(markdown);
|
||||||
|
setState("copied");
|
||||||
|
setTimeout(() => setState("idle"), 2000);
|
||||||
|
} catch {
|
||||||
|
setState("idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={state === "loading"}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||||
|
aria-label="Copy page as Markdown"
|
||||||
|
>
|
||||||
|
{state === "copied" ? (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
Copied
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
Copy Page
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1436
-177
File diff suppressed because it is too large
Load Diff
@@ -1,29 +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 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-900 dark:text-green-100"
|
|
||||||
: alertType === "warning"
|
|
||||||
? "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100"
|
|
||||||
: alertType === "error"
|
|
||||||
? "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100"
|
|
||||||
: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100";
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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}`} />;
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
>;
|
|
||||||
@@ -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 }));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useEffect, useState } from "react";
|
||||||
|
import { useChat } from "@ai-sdk/react";
|
||||||
|
import { DefaultChatTransport } from "ai";
|
||||||
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "docs-chat-messages";
|
||||||
|
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||||
|
|
||||||
|
export function DocsChat() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const restoredRef = useRef(false);
|
||||||
|
|
||||||
|
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
||||||
|
|
||||||
|
const isLoading = status === "streaming" || status === "submitted";
|
||||||
|
|
||||||
|
// Restore messages from sessionStorage on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoredRef.current) return;
|
||||||
|
restoredRef.current = true;
|
||||||
|
try {
|
||||||
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
setMessages(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}, [setMessages]);
|
||||||
|
|
||||||
|
// Save completed messages to sessionStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restoredRef.current) return;
|
||||||
|
if (isLoading) return;
|
||||||
|
if (messages.length === 0) {
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||||
|
} catch {
|
||||||
|
// ignore quota errors
|
||||||
|
}
|
||||||
|
}, [messages, isLoading]);
|
||||||
|
|
||||||
|
// Auto-open when new messages arrive
|
||||||
|
const prevMessageCount = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (messages.length > prevMessageCount.current) {
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
prevMessageCount.current = messages.length;
|
||||||
|
}, [messages.length]);
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Close message area when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!input.trim() || isLoading) return;
|
||||||
|
sendMessage({ text: input });
|
||||||
|
setInput("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setMessages([]);
|
||||||
|
sessionStorage.removeItem(STORAGE_KEY);
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTextFromParts = (
|
||||||
|
parts: (typeof messages)[number]["parts"],
|
||||||
|
): string => {
|
||||||
|
return parts
|
||||||
|
.filter(
|
||||||
|
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||||
|
)
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
||||||
|
>
|
||||||
|
{/* Messages panel */}
|
||||||
|
{open && messages.length > 0 && (
|
||||||
|
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
|
json-render Docs
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Clear conversation"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-4 overflow-y-auto">
|
||||||
|
{messages.map((message) => {
|
||||||
|
const text = getTextFromParts(message.parts);
|
||||||
|
if (!text) return null;
|
||||||
|
return (
|
||||||
|
<div key={message.id}>
|
||||||
|
{message.role === "assistant" ? (
|
||||||
|
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Streamdown>{text}</Streamdown>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onClick={() => inputRef.current?.focus()}
|
||||||
|
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => {
|
||||||
|
setInput(e.target.value);
|
||||||
|
e.target.style.height = "auto";
|
||||||
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||||
|
}}
|
||||||
|
placeholder="Ask about the docs..."
|
||||||
|
rows={1}
|
||||||
|
onFocus={() => {
|
||||||
|
if (messages.length > 0) setOpen(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
setOpen(false);
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading || !input.trim()}
|
||||||
|
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="5" />
|
||||||
|
<polyline points="5 12 12 5 19 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,53 +10,15 @@ import {
|
|||||||
SheetContent,
|
SheetContent,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
|
||||||
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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Flatten all pages for current page lookup
|
|
||||||
const allPages = navigation.flatMap((section) => section.items);
|
|
||||||
|
|
||||||
export function DocsMobileNav() {
|
export function DocsMobileNav() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
const currentPage = useMemo(() => {
|
const currentPage = useMemo(() => {
|
||||||
const page = allPages.find((page) => page.href === pathname);
|
const page = allDocsPages.find((page) => page.href === pathname);
|
||||||
return page ?? allPages[0];
|
return page ?? allDocsPages[0];
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,27 +32,49 @@ export function DocsMobileNav() {
|
|||||||
<SheetContent className="overflow-y-auto p-6">
|
<SheetContent className="overflow-y-auto p-6">
|
||||||
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
|
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
|
||||||
<nav className="space-y-6">
|
<nav className="space-y-6">
|
||||||
{navigation.map((section) => (
|
{docsNavigation.map((section) => (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
|
||||||
{section.title}
|
{section.title}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => {
|
||||||
<li key={item.href}>
|
const isExternal = item.external;
|
||||||
<Link
|
return (
|
||||||
href={item.href}
|
<li key={item.href}>
|
||||||
onClick={() => setOpen(false)}
|
<Link
|
||||||
className={`text-sm block py-2 transition-colors ${
|
href={item.href}
|
||||||
pathname === item.href
|
onClick={() => setOpen(false)}
|
||||||
? "text-foreground font-medium"
|
{...(isExternal && {
|
||||||
: "text-muted-foreground hover:text-foreground"
|
target: "_blank",
|
||||||
}`}
|
rel: "noopener noreferrer",
|
||||||
>
|
})}
|
||||||
{item.title}
|
className={`text-sm block py-2 transition-colors ${
|
||||||
</Link>
|
pathname === item.href
|
||||||
</li>
|
? "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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import { ThemeToggle } from "./theme-toggle";
|
import { ThemeToggle } from "./theme-toggle";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function Header() {
|
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 (
|
return (
|
||||||
<header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
|
<header className="sticky top-0 z-50 bg-background">
|
||||||
<div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
|
<div className="flex h-14 items-center justify-between px-4 gap-6">
|
||||||
<Link
|
<div className="flex items-center gap-2">
|
||||||
href="/"
|
<Link href="https://vercel.com" title="Made with love by Vercel">
|
||||||
className="font-semibold hover:opacity-70 transition-opacity"
|
<svg
|
||||||
>
|
data-testid="geist-icon"
|
||||||
json-render
|
height="18"
|
||||||
</Link>
|
strokeLinejoin="round"
|
||||||
<nav className="flex gap-4 items-center text-sm">
|
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
|
<Link
|
||||||
href="/docs"
|
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
|
Docs
|
||||||
</Link>
|
</Link>
|
||||||
@@ -22,9 +90,17 @@ export function Header() {
|
|||||||
href="https://github.com/vercel-labs/json-render"
|
href="https://github.com/vercel-labs/json-render"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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>10.1k</span>
|
||||||
</a>
|
</a>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ChevronDownIcon } from "lucide-react";
|
||||||
|
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Accordion({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||||
|
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("border-b last:border-b-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Content
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||||
|
</AccordionPrimitive.Content>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||||
@@ -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 };
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -22,9 +22,11 @@ const buttonVariants = cva(
|
|||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
icon: "size-9",
|
icon: "size-9",
|
||||||
|
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||||
"icon-sm": "size-8",
|
"icon-sm": "size-8",
|
||||||
"icon-lg": "size-10",
|
"icon-lg": "size-10",
|
||||||
},
|
},
|
||||||
@@ -46,7 +48,7 @@ function Button({
|
|||||||
VariantProps<typeof buttonVariants> & {
|
VariantProps<typeof buttonVariants> & {
|
||||||
asChild?: boolean;
|
asChild?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const Comp = asChild ? Slot : "button";
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user