mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ea9c782b | ||
|
|
dd17549ee8 |
@@ -6,7 +6,6 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-native",
|
||||
"@json-render/remotion",
|
||||
"@json-render/codegen"
|
||||
]
|
||||
|
||||
@@ -21,15 +21,11 @@ coverage
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
# Debug
|
||||
|
||||
@@ -100,9 +100,8 @@ function Dashboard({ spec }) {
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, 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
|
||||
@@ -198,39 +197,18 @@ const systemPrompt = catalog.prompt();
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Props
|
||||
|
||||
Any prop value can be data-driven using expressions:
|
||||
### Data Binding
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Icon",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
"label": "Revenue",
|
||||
"value": "{{data.revenue}}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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
|
||||
{
|
||||
"type": "Pressable",
|
||||
"props": { "action": "setState", "actionParams": { "path": "/activeTab", "value": "home" } },
|
||||
"children": ["home-icon"]
|
||||
}
|
||||
```
|
||||
|
||||
The `setState` action updates the data model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
|
||||
@@ -1,25 +1,49 @@
|
||||
export const metadata = { title: "A2UI Integration" }
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
# A2UI Integration
|
||||
export const metadata = {
|
||||
title: "A2UI Integration | json-render",
|
||||
};
|
||||
|
||||
Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
|
||||
export default function A2UIPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">A2UI Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use <code className="text-foreground">@json-render/core</code> to
|
||||
support{" "}
|
||||
<a
|
||||
href="https://a2ui.org"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
A2UI
|
||||
</a>{" "}
|
||||
natively.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can support A2UI. The examples are illustrative and may require adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/core</code> is
|
||||
schema-agnostic. Define a catalog that matches A2UI's format and
|
||||
build a renderer that understands it - no conversion layer needed.
|
||||
</p>
|
||||
|
||||
`@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
|
||||
{
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Example A2UI Message</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A2UI uses an adjacency list model - a flat list of components with ID
|
||||
references. This makes it easy to patch individual components:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"surfaceUpdate": {
|
||||
"surfaceId": "main",
|
||||
"components": [
|
||||
@@ -59,13 +83,12 @@ A2UI uses an adjacency list model - a flat list of components with ID references
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## Define the A2UI Catalog
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the A2UI Catalog
|
||||
</h2>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
// A2UI BoundValue schema
|
||||
@@ -128,15 +151,15 @@ export const a2uiCatalog = createCatalog({
|
||||
},
|
||||
// Add more A2UI standard components...
|
||||
},
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
## Define the A2UI Schema
|
||||
|
||||
Define the schema for A2UI message types:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the A2UI Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define the schema for A2UI message types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
|
||||
// Component instance in the adjacency list
|
||||
const A2UIComponent = z.object({
|
||||
@@ -150,8 +173,8 @@ const SurfaceUpdate = z.object({
|
||||
components: z.array(A2UIComponent),
|
||||
});
|
||||
|
||||
// State model update message
|
||||
const StateModelUpdate = z.object({
|
||||
// Data model update message
|
||||
const DataModelUpdate = z.object({
|
||||
surfaceId: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
contents: z.array(z.object({
|
||||
@@ -173,18 +196,18 @@ const BeginRendering = z.object({
|
||||
// Complete A2UI message schema
|
||||
export const A2UIMessage = z.object({
|
||||
surfaceUpdate: SurfaceUpdate.optional(),
|
||||
dataModelUpdate: StateModelUpdate.optional(),
|
||||
dataModelUpdate: DataModelUpdate.optional(),
|
||||
beginRendering: BeginRendering.optional(),
|
||||
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
## Build an A2UI Renderer
|
||||
|
||||
Create a renderer that processes the A2UI adjacency list format:
|
||||
|
||||
```tsx
|
||||
import { a2uiCatalog } from './catalog';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Build an A2UI Renderer
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a renderer that processes the A2UI adjacency list format:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { a2uiCatalog } from './catalog';
|
||||
|
||||
// Component registry
|
||||
const components = {
|
||||
@@ -216,7 +239,7 @@ export function renderA2UI(
|
||||
if (!bound) return undefined;
|
||||
if (bound.literalString) return bound.literalString;
|
||||
if (bound.path) {
|
||||
const parts = bound.path.replace(/^\//, '').split('/');
|
||||
const parts = bound.path.replace(/^\\//, '').split('/');
|
||||
let value = dataModel;
|
||||
for (const p of parts) value = value?.[p];
|
||||
return value;
|
||||
@@ -249,13 +272,10 @@ export function renderA2UI(
|
||||
}
|
||||
|
||||
return render(rootId);
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const [components] = useState(() => new Map());
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
|
||||
<Code lang="tsx">{`const [components] = useState(() => new Map());
|
||||
const [dataModel, setDataModel] = useState({});
|
||||
const [rootId, setRootId] = useState<string | null>(null);
|
||||
|
||||
@@ -275,9 +295,19 @@ function handleMessage(msg: any) {
|
||||
}
|
||||
|
||||
// Render
|
||||
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}
|
||||
```
|
||||
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol.
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/adaptive-cards"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Adaptive Cards integration
|
||||
</Link>{" "}
|
||||
for another UI protocol.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,406 +0,0 @@
|
||||
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,791 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Adaptive Cards Integration | json-render",
|
||||
};
|
||||
|
||||
export default function AdaptiveCardsPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use json-render to render{" "}
|
||||
<a
|
||||
href="https://adaptivecards.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Microsoft Adaptive Cards
|
||||
</a>{" "}
|
||||
natively.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can
|
||||
support Adaptive Cards. The examples are illustrative and may require
|
||||
adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Adaptive Cards Overview
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
|
||||
Cards have a <code className="text-foreground">body</code> array of
|
||||
elements and an optional{" "}
|
||||
<code className="text-foreground">actions</code> array for interactive
|
||||
buttons.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
|
||||
<Code lang="json">{`{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Hello, Adaptive Cards!",
|
||||
"size": "large",
|
||||
"weight": "bolder"
|
||||
},
|
||||
{
|
||||
"type": "Image",
|
||||
"url": "https://example.com/image.png",
|
||||
"altText": "Example image"
|
||||
},
|
||||
{
|
||||
"type": "Container",
|
||||
"items": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "This is inside a container",
|
||||
"wrap": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "auto",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "Column 1" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "stretch",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "Column 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"id": "userInput",
|
||||
"placeholder": "Enter your name",
|
||||
"label": "Name"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": "Submit"
|
||||
},
|
||||
{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "Learn More",
|
||||
"url": "https://adaptivecards.io"
|
||||
}
|
||||
]
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Creating an Adaptive Cards Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define a catalog matching the Adaptive Cards element types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common Adaptive Cards properties
|
||||
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
|
||||
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
|
||||
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
|
||||
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
|
||||
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
|
||||
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
|
||||
const ImageStyle = z.enum(['default', 'person']);
|
||||
|
||||
// Base element properties shared by most elements
|
||||
const BaseElement = {
|
||||
id: z.string().optional(),
|
||||
isVisible: z.boolean().optional(),
|
||||
separator: z.boolean().optional(),
|
||||
spacing: Spacing.optional(),
|
||||
};
|
||||
|
||||
export const adaptiveCardsCatalog = createCatalog({
|
||||
components: {
|
||||
// Root card
|
||||
AdaptiveCard: {
|
||||
description: 'Root Adaptive Card container',
|
||||
props: z.object({
|
||||
version: z.string(),
|
||||
body: z.array(z.unknown()).optional(),
|
||||
actions: z.array(z.unknown()).optional(),
|
||||
fallbackText: z.string().optional(),
|
||||
minHeight: z.string().optional(),
|
||||
rtl: z.boolean().optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Elements
|
||||
TextBlock: {
|
||||
description: 'Displays text with formatting options',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
text: z.string(),
|
||||
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
|
||||
fontType: z.enum(['default', 'monospace']).optional(),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
isSubtle: z.boolean().optional(),
|
||||
maxLines: z.number().optional(),
|
||||
size: FontSize.optional(),
|
||||
weight: FontWeight.optional(),
|
||||
wrap: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Image: {
|
||||
description: 'Displays an image',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
url: z.string(),
|
||||
altText: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
height: z.string().optional(),
|
||||
width: z.string().optional(),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
size: ImageSize.optional(),
|
||||
style: ImageStyle.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Container: {
|
||||
description: 'Groups elements together',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
items: z.array(z.unknown()),
|
||||
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
bleed: z.boolean().optional(),
|
||||
minHeight: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ColumnSet: {
|
||||
description: 'Arranges columns horizontally',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
columns: z.array(z.unknown()),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
minHeight: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description: 'A column within a ColumnSet',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
items: z.array(z.unknown()).optional(),
|
||||
width: z.union([z.string(), z.number()]).optional(),
|
||||
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
FactSet: {
|
||||
description: 'Displays a series of facts as key/value pairs',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
facts: z.array(z.object({
|
||||
title: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
|
||||
ImageSet: {
|
||||
description: 'Displays a collection of images',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
images: z.array(z.object({
|
||||
type: z.literal('Image'),
|
||||
url: z.string(),
|
||||
altText: z.string().optional(),
|
||||
})),
|
||||
imageSize: ImageSize.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ActionSet: {
|
||||
description: 'Displays a set of actions',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
actions: z.array(z.unknown()),
|
||||
}),
|
||||
},
|
||||
|
||||
RichTextBlock: {
|
||||
description: 'Rich text with inline formatting',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
inlines: z.array(z.unknown()),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Inputs
|
||||
'Input.Text': {
|
||||
description: 'Text input field',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
isMultiline: z.boolean().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Number': {
|
||||
description: 'Number input field',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
max: z.number().optional(),
|
||||
min: z.number().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.number().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Date': {
|
||||
description: 'Date picker input',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
max: z.string().optional(),
|
||||
min: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Time': {
|
||||
description: 'Time picker input',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
max: z.string().optional(),
|
||||
min: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Toggle': {
|
||||
description: 'Toggle/checkbox input',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
valueOff: z.string().optional(),
|
||||
valueOn: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.ChoiceSet': {
|
||||
description: 'Dropdown or radio/checkbox group',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
choices: z.array(z.object({
|
||||
title: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
isMultiSelect: z.boolean().optional(),
|
||||
style: z.enum(['compact', 'expanded']).optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Actions
|
||||
'Action.OpenUrl': {
|
||||
description: 'Opens a URL',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
url: z.string(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.Submit': {
|
||||
description: 'Submits input data',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.ShowCard': {
|
||||
description: 'Shows a card inline',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
card: z.unknown(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.ToggleVisibility': {
|
||||
description: 'Toggles visibility of elements',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
targetElements: z.array(z.union([
|
||||
z.string(),
|
||||
z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
|
||||
])),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.Execute': {
|
||||
description: 'Universal action for bots',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
verb: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Building an Adaptive Cards Renderer
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a renderer that processes Adaptive Cards JSON:
|
||||
</p>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface AdaptiveCardElement {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AdaptiveCard {
|
||||
type: 'AdaptiveCard';
|
||||
version: string;
|
||||
body?: AdaptiveCardElement[];
|
||||
actions?: AdaptiveCardElement[];
|
||||
}
|
||||
|
||||
interface RenderContext {
|
||||
onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
|
||||
inputs: Record<string, unknown>;
|
||||
setInput: (id: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
// Widget registry for Adaptive Cards elements
|
||||
const widgets: Record<string, React.FC<any>> = {
|
||||
TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
|
||||
const sizeClass = {
|
||||
small: 'text-xs',
|
||||
default: 'text-sm',
|
||||
medium: 'text-base',
|
||||
large: 'text-lg',
|
||||
extraLarge: 'text-2xl',
|
||||
}[size || 'default'];
|
||||
|
||||
const weightClass = {
|
||||
lighter: 'font-light',
|
||||
default: 'font-normal',
|
||||
bolder: 'font-bold',
|
||||
}[weight || 'default'];
|
||||
|
||||
const alignClass = {
|
||||
left: 'text-left',
|
||||
center: 'text-center',
|
||||
right: 'text-right',
|
||||
}[horizontalAlignment || 'left'];
|
||||
|
||||
return (
|
||||
<p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
},
|
||||
|
||||
Image: ({ url, altText, size, style, horizontalAlignment }) => {
|
||||
const sizeClass = {
|
||||
auto: '',
|
||||
stretch: 'w-full',
|
||||
small: 'w-16',
|
||||
medium: 'w-32',
|
||||
large: 'w-48',
|
||||
}[size || 'auto'];
|
||||
|
||||
return (
|
||||
<div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
|
||||
<img
|
||||
src={url}
|
||||
alt={altText || ''}
|
||||
className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Container: ({ items, style, children, ctx }) => {
|
||||
const styleClass = {
|
||||
default: '',
|
||||
emphasis: 'bg-muted p-2 rounded',
|
||||
good: 'bg-green-50 p-2 rounded',
|
||||
attention: 'bg-red-50 p-2 rounded',
|
||||
warning: 'bg-yellow-50 p-2 rounded',
|
||||
accent: 'bg-blue-50 p-2 rounded',
|
||||
}[style || 'default'];
|
||||
|
||||
return (
|
||||
<div className={\`\${styleClass} space-y-2\`}>
|
||||
{children || items?.map((item: any, i: number) => (
|
||||
<AdaptiveElement key={i} element={item} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
ColumnSet: ({ columns, ctx }) => (
|
||||
<div className="flex gap-2">
|
||||
{columns?.map((col: any, i: number) => (
|
||||
<AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
Column: ({ items, width, style, ctx }) => {
|
||||
const widthClass = width === 'auto' ? 'flex-none' :
|
||||
width === 'stretch' ? 'flex-1' :
|
||||
typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
|
||||
return (
|
||||
<div className={\`\${widthClass} space-y-2\`}>
|
||||
{items?.map((item: any, i: number) => (
|
||||
<AdaptiveElement key={i} element={item} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
FactSet: ({ facts }) => (
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{facts?.map((fact: any, i: number) => (
|
||||
<React.Fragment key={i}>
|
||||
<span className="font-medium">{fact.title}</span>
|
||||
<span>{fact.value}</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
ActionSet: ({ actions, ctx }) => (
|
||||
<div className="flex gap-2 pt-2">
|
||||
{actions?.map((action: any, i: number) => (
|
||||
<AdaptiveElement key={i} element={action} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
|
||||
'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{isMultiline ? (
|
||||
<textarea
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
onChange={(e) => ctx.setInput(id, e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
onChange={(e) => ctx.setInput(id, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
placeholder={placeholder}
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={value}
|
||||
onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={id}
|
||||
defaultChecked={value === valueOn}
|
||||
onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
|
||||
/>
|
||||
<label htmlFor={id} className="text-sm">{title || label}</label>
|
||||
</div>
|
||||
),
|
||||
|
||||
'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
|
||||
<div className="space-y-1">
|
||||
{label && <label className="text-sm font-medium">{label}</label>}
|
||||
{style === 'expanded' ? (
|
||||
<div className="space-y-1">
|
||||
{choices?.map((choice: any, i: number) => (
|
||||
<label key={i} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type={isMultiSelect ? 'checkbox' : 'radio'}
|
||||
name={id}
|
||||
value={choice.value}
|
||||
onChange={(e) => ctx.setInput(id, e.target.value)}
|
||||
/>
|
||||
{choice.title}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
onChange={(e) => ctx.setInput(id, e.target.value)}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{choices?.map((choice: any, i: number) => (
|
||||
<option key={i} value={choice.value}>{choice.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
'Action.Submit': ({ title, data, ctx }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
|
||||
onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
|
||||
>
|
||||
{title || 'Submit'}
|
||||
</button>
|
||||
),
|
||||
|
||||
'Action.OpenUrl': ({ title, url }) => (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 border rounded text-sm hover:bg-muted"
|
||||
>
|
||||
{title || 'Open'}
|
||||
</a>
|
||||
),
|
||||
|
||||
'Action.Execute': ({ title, verb, data, ctx }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
|
||||
onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
|
||||
>
|
||||
{title || 'Execute'}
|
||||
</button>
|
||||
),
|
||||
};
|
||||
|
||||
function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
|
||||
const Widget = widgets[element.type];
|
||||
if (!Widget) {
|
||||
console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
|
||||
return null;
|
||||
}
|
||||
return <Widget {...element} ctx={ctx} />;
|
||||
}
|
||||
|
||||
export function AdaptiveCardRenderer({
|
||||
card,
|
||||
onAction,
|
||||
}: {
|
||||
card: AdaptiveCard;
|
||||
onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
|
||||
|
||||
const ctx: RenderContext = {
|
||||
onAction: onAction || (() => {}),
|
||||
inputs,
|
||||
setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-4 space-y-3 max-w-md">
|
||||
{card.body?.map((element, i) => (
|
||||
<AdaptiveElement key={i} element={element} ctx={ctx} />
|
||||
))}
|
||||
{card.actions && card.actions.length > 0 && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
{card.actions.map((action, i) => (
|
||||
<AdaptiveElement key={i} element={action} ctx={ctx} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Render an Adaptive Card and handle actions:
|
||||
</p>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
|
||||
|
||||
const card = {
|
||||
type: 'AdaptiveCard' as const,
|
||||
version: '1.5',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: 'Contact Form',
|
||||
size: 'large',
|
||||
weight: 'bolder',
|
||||
},
|
||||
{
|
||||
type: 'Input.Text',
|
||||
id: 'name',
|
||||
label: 'Your Name',
|
||||
placeholder: 'Enter your name',
|
||||
},
|
||||
{
|
||||
type: 'Input.Text',
|
||||
id: 'message',
|
||||
label: 'Message',
|
||||
placeholder: 'Enter your message',
|
||||
isMultiline: true,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'Action.Submit',
|
||||
title: 'Send',
|
||||
data: { action: 'submitForm' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function ContactCard() {
|
||||
const handleAction = (action: any, inputData: Record<string, unknown>) => {
|
||||
console.log('Action:', action);
|
||||
console.log('Input data:', inputData);
|
||||
|
||||
// Send to your backend
|
||||
fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, data: inputData }),
|
||||
});
|
||||
};
|
||||
|
||||
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Handling Action.Execute for Bots
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
For bot scenarios, handle{" "}
|
||||
<code className="text-foreground">Action.Execute</code> with the verb
|
||||
and data:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface ActionExecutePayload {
|
||||
action: {
|
||||
type: 'Action.Execute';
|
||||
verb: string;
|
||||
data?: unknown;
|
||||
};
|
||||
inputs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function handleBotAction(payload: ActionExecutePayload) {
|
||||
const response = await fetch('/api/bot/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
verb: payload.action.verb,
|
||||
data: payload.action.data,
|
||||
inputs: payload.inputs,
|
||||
}),
|
||||
});
|
||||
|
||||
// Bot may return a new card to render
|
||||
const result = await response.json();
|
||||
if (result.card) {
|
||||
return result.card; // New AdaptiveCard to render
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/a2ui" className="text-foreground hover:underline">
|
||||
A2UI integration
|
||||
</Link>{" "}
|
||||
for another agent-driven UI protocol.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
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,651 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "AG-UI Integration | json-render",
|
||||
};
|
||||
|
||||
export default function AGUIPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">AG-UI Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use json-render to support{" "}
|
||||
<a
|
||||
href="https://docs.copilotkit.ai/ag-ui"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
AG-UI
|
||||
</a>{" "}
|
||||
(Agent User Interaction Protocol) from CopilotKit.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can
|
||||
support AG-UI. The examples are illustrative and may require
|
||||
adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is AG-UI?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
AG-UI is an open protocol for connecting AI agents to user interfaces.
|
||||
It provides a standardized way for agents to render UI components,
|
||||
handle user input, and manage state. The protocol uses events streamed
|
||||
over HTTP to update the UI in real-time.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">AG-UI Event Types</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
AG-UI defines several event types for agent-UI communication:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">TEXT_MESSAGE_START</code> /{" "}
|
||||
<code className="text-foreground">TEXT_MESSAGE_CONTENT</code> /{" "}
|
||||
<code className="text-foreground">TEXT_MESSAGE_END</code> — Streaming
|
||||
text messages
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">TOOL_CALL_START</code> /{" "}
|
||||
<code className="text-foreground">TOOL_CALL_ARGS</code> /{" "}
|
||||
<code className="text-foreground">TOOL_CALL_END</code> — Tool/function
|
||||
calls
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">STATE_SNAPSHOT</code> /{" "}
|
||||
<code className="text-foreground">STATE_DELTA</code> — State updates
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">CUSTOM</code> — Custom events for UI
|
||||
rendering
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">
|
||||
Example AG-UI Event Stream
|
||||
</h3>
|
||||
<Code lang="json">{`{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
|
||||
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
|
||||
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
|
||||
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
|
||||
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
|
||||
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\\"component\\": \\"Dashboard\\", \\"props\\": {\\"title\\": \\"Sales\\"}}"}
|
||||
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
|
||||
{"type": "RUN_FINISHED"}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the AG-UI Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define schemas for AG-UI event types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
|
||||
// Base event schema
|
||||
const BaseEvent = z.object({
|
||||
type: z.string(),
|
||||
timestamp: z.number().optional(),
|
||||
});
|
||||
|
||||
// Text message events
|
||||
const TextMessageStart = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_START'),
|
||||
messageId: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
});
|
||||
|
||||
const TextMessageContent = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_CONTENT'),
|
||||
messageId: z.string(),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
const TextMessageEnd = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_END'),
|
||||
messageId: z.string(),
|
||||
});
|
||||
|
||||
// Tool call events
|
||||
const ToolCallStart = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_START'),
|
||||
toolCallId: z.string(),
|
||||
toolCallName: z.string(),
|
||||
parentMessageId: z.string().optional(),
|
||||
});
|
||||
|
||||
const ToolCallArgs = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_ARGS'),
|
||||
toolCallId: z.string(),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
const ToolCallEnd = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_END'),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
// State events
|
||||
const StateSnapshot = BaseEvent.extend({
|
||||
type: z.literal('STATE_SNAPSHOT'),
|
||||
snapshot: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
const StateDelta = BaseEvent.extend({
|
||||
type: z.literal('STATE_DELTA'),
|
||||
delta: z.array(z.object({
|
||||
op: z.enum(['add', 'remove', 'replace']),
|
||||
path: z.string(),
|
||||
value: z.unknown().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Custom event for UI components
|
||||
const CustomEvent = BaseEvent.extend({
|
||||
type: z.literal('CUSTOM'),
|
||||
name: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
// Run lifecycle events
|
||||
const RunStarted = BaseEvent.extend({
|
||||
type: z.literal('RUN_STARTED'),
|
||||
threadId: z.string(),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
const RunFinished = BaseEvent.extend({
|
||||
type: z.literal('RUN_FINISHED'),
|
||||
});
|
||||
|
||||
const RunError = BaseEvent.extend({
|
||||
type: z.literal('RUN_ERROR'),
|
||||
message: z.string(),
|
||||
code: z.string().optional(),
|
||||
});
|
||||
|
||||
// Union of all events
|
||||
export const AGUIEvent = z.discriminatedUnion('type', [
|
||||
TextMessageStart,
|
||||
TextMessageContent,
|
||||
TextMessageEnd,
|
||||
ToolCallStart,
|
||||
ToolCallArgs,
|
||||
ToolCallEnd,
|
||||
StateSnapshot,
|
||||
StateDelta,
|
||||
CustomEvent,
|
||||
RunStarted,
|
||||
RunFinished,
|
||||
RunError,
|
||||
]);
|
||||
|
||||
export type AGUIEvent = z.infer<typeof AGUIEvent>;`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the AG-UI Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a catalog for UI components that agents can render:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aguiCatalog = createCatalog({
|
||||
components: {
|
||||
// Layout components
|
||||
Container: {
|
||||
description: 'A container for grouping elements',
|
||||
props: z.object({
|
||||
direction: z.enum(['row', 'column']).optional(),
|
||||
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
||||
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Card: {
|
||||
description: 'A card with optional title',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Content components
|
||||
Text: {
|
||||
description: 'Text content',
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Markdown: {
|
||||
description: 'Renders markdown content',
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
},
|
||||
|
||||
Image: {
|
||||
description: 'Displays an image',
|
||||
props: z.object({
|
||||
src: z.string(),
|
||||
alt: z.string().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Data display
|
||||
Table: {
|
||||
description: 'Displays tabular data',
|
||||
props: z.object({
|
||||
columns: z.array(z.object({
|
||||
key: z.string(),
|
||||
header: z.string(),
|
||||
width: z.string().optional(),
|
||||
})),
|
||||
data: z.array(z.record(z.unknown())),
|
||||
}),
|
||||
},
|
||||
|
||||
Chart: {
|
||||
description: 'Renders a chart',
|
||||
props: z.object({
|
||||
type: z.enum(['line', 'bar', 'pie', 'area']),
|
||||
data: z.array(z.record(z.unknown())),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
title: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Metric: {
|
||||
description: 'Displays a metric value',
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
change: z.number().optional(),
|
||||
format: z.enum(['number', 'currency', 'percent']).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Input components
|
||||
Button: {
|
||||
description: 'Interactive button',
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Input: {
|
||||
description: 'Text input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
type: z.enum(['text', 'email', 'password', 'number']).optional(),
|
||||
required: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Select: {
|
||||
description: 'Dropdown select',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string().optional(),
|
||||
options: z.array(z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
})),
|
||||
placeholder: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Form: {
|
||||
description: 'Form container',
|
||||
props: z.object({
|
||||
id: z.string(),
|
||||
submitLabel: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Feedback components
|
||||
Alert: {
|
||||
description: 'Alert message',
|
||||
props: z.object({
|
||||
message: z.string(),
|
||||
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Progress: {
|
||||
description: 'Progress indicator',
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
description: 'Loading placeholder',
|
||||
props: z.object({
|
||||
width: z.string().optional(),
|
||||
height: z.string().optional(),
|
||||
variant: z.enum(['text', 'circular', 'rectangular']).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit: {
|
||||
description: 'Submit form data',
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
},
|
||||
navigate: {
|
||||
description: 'Navigate to a URL',
|
||||
params: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
},
|
||||
callback: {
|
||||
description: 'Trigger a callback to the agent',
|
||||
params: z.object({
|
||||
name: z.string(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Build an AG-UI Event Processor
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Process AG-UI events and render UI components:
|
||||
</p>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { AGUIEvent } from './schema';
|
||||
|
||||
interface AGUIState {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}>;
|
||||
toolCalls: Map<string, {
|
||||
name: string;
|
||||
args: string;
|
||||
result?: unknown;
|
||||
}>;
|
||||
state: Record<string, unknown>;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function useAGUI() {
|
||||
const [aguiState, setAGUIState] = useState<AGUIState>({
|
||||
messages: [],
|
||||
toolCalls: new Map(),
|
||||
state: {},
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const processEvent = useCallback((event: AGUIEvent) => {
|
||||
switch (event.type) {
|
||||
case 'RUN_STARTED':
|
||||
setAGUIState(prev => ({ ...prev, isRunning: true }));
|
||||
break;
|
||||
|
||||
case 'RUN_FINISHED':
|
||||
setAGUIState(prev => ({ ...prev, isRunning: false }));
|
||||
break;
|
||||
|
||||
case 'TEXT_MESSAGE_START':
|
||||
setAGUIState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, {
|
||||
id: event.messageId,
|
||||
role: event.role,
|
||||
content: '',
|
||||
}],
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'TEXT_MESSAGE_CONTENT':
|
||||
setAGUIState(prev => ({
|
||||
...prev,
|
||||
messages: prev.messages.map(msg =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: msg.content + event.delta }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'TOOL_CALL_START':
|
||||
setAGUIState(prev => {
|
||||
const toolCalls = new Map(prev.toolCalls);
|
||||
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
|
||||
return { ...prev, toolCalls };
|
||||
});
|
||||
break;
|
||||
|
||||
case 'TOOL_CALL_ARGS':
|
||||
setAGUIState(prev => {
|
||||
const toolCalls = new Map(prev.toolCalls);
|
||||
const tc = toolCalls.get(event.toolCallId);
|
||||
if (tc) {
|
||||
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
|
||||
}
|
||||
return { ...prev, toolCalls };
|
||||
});
|
||||
break;
|
||||
|
||||
case 'STATE_SNAPSHOT':
|
||||
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
|
||||
break;
|
||||
|
||||
case 'STATE_DELTA':
|
||||
setAGUIState(prev => {
|
||||
const newState = { ...prev.state };
|
||||
for (const op of event.delta) {
|
||||
const parts = op.path.split('/').filter(Boolean);
|
||||
if (op.op === 'replace' || op.op === 'add') {
|
||||
let obj: any = newState;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
obj = obj[parts[i]] = obj[parts[i]] || {};
|
||||
}
|
||||
obj[parts[parts.length - 1]] = op.value;
|
||||
} else if (op.op === 'remove') {
|
||||
let obj: any = newState;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
obj = obj[parts[i]];
|
||||
if (!obj) break;
|
||||
}
|
||||
if (obj) delete obj[parts[parts.length - 1]];
|
||||
}
|
||||
}
|
||||
return { ...prev, state: newState };
|
||||
});
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { state: aguiState, processEvent };
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Rendering Tool Call Results as UI
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
When an agent calls a <code className="text-foreground">render_ui</code>{" "}
|
||||
tool, parse the arguments and render the component:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { aguiCatalog } from './catalog';
|
||||
|
||||
// Component registry
|
||||
const components: Record<string, React.FC<any>> = {
|
||||
Container: ({ direction = 'column', gap = 'md', children }) => (
|
||||
<div className={\`flex flex-\${direction} gap-\${gap}\`}>{children}</div>
|
||||
),
|
||||
Card: ({ title, description, children }) => (
|
||||
<div className="border rounded-lg p-4">
|
||||
{title && <h3 className="font-semibold">{title}</h3>}
|
||||
{description && <p className="text-muted-foreground text-sm">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Text: ({ content, variant = 'body' }) => {
|
||||
const styles = {
|
||||
body: 'text-sm',
|
||||
heading: 'text-lg font-semibold',
|
||||
caption: 'text-xs text-muted-foreground',
|
||||
code: 'font-mono text-sm bg-muted px-1 rounded',
|
||||
};
|
||||
return <p className={styles[variant]}>{content}</p>;
|
||||
},
|
||||
Button: ({ label, variant = 'primary', onClick }) => (
|
||||
<button
|
||||
className={\`px-4 py-2 rounded text-sm \${
|
||||
variant === 'primary' ? 'bg-primary text-primary-foreground' :
|
||||
variant === 'secondary' ? 'bg-secondary text-secondary-foreground' :
|
||||
'border'
|
||||
}\`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
Metric: ({ label, value, change, format }) => {
|
||||
const formatted = format === 'currency' ? \`$\${value.toLocaleString()}\` :
|
||||
format === 'percent' ? \`\${value}%\` :
|
||||
value.toLocaleString();
|
||||
return (
|
||||
<div className="p-4 border rounded">
|
||||
<p className="text-sm text-muted-foreground">{label}</p>
|
||||
<p className="text-2xl font-bold">{formatted}</p>
|
||||
{change !== undefined && (
|
||||
<p className={\`text-sm \${change >= 0 ? 'text-green-600' : 'text-red-600'}\`}>
|
||||
{change >= 0 ? '+' : ''}{change}%
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
Alert: ({ message, type = 'info' }) => {
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||
success: 'bg-green-50 text-green-800 border-green-200',
|
||||
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||
error: 'bg-red-50 text-red-800 border-red-200',
|
||||
};
|
||||
return <div className={\`p-3 rounded border \${styles[type]}\`}>{message}</div>;
|
||||
},
|
||||
// Add more components...
|
||||
};
|
||||
|
||||
// Render tool call result
|
||||
export function renderToolCallUI(toolCall: { name: string; args: string }) {
|
||||
if (toolCall.name !== 'render_ui') return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(toolCall.args);
|
||||
const Component = components[parsed.component];
|
||||
if (!Component) return null;
|
||||
|
||||
return <Component {...parsed.props} />;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import { useAGUI } from './use-agui';
|
||||
import { renderToolCallUI } from './renderer';
|
||||
|
||||
export function AGUIChat() {
|
||||
const { state, processEvent } = useAGUI();
|
||||
|
||||
// Connect to AG-UI event stream
|
||||
async function startRun(prompt: string) {
|
||||
const response = await fetch('/api/agent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const lines = decoder.decode(value).split('\\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
processEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Messages */}
|
||||
{state.messages.map(msg => (
|
||||
<div key={msg.id} className={\`p-3 rounded \${
|
||||
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
|
||||
}\`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Rendered UI from tool calls */}
|
||||
{Array.from(state.toolCalls.values()).map((tc, i) => (
|
||||
<div key={i}>{renderToolCallUI(tc)}</div>
|
||||
))}
|
||||
|
||||
{/* Input */}
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.querySelector('input');
|
||||
if (input?.value) {
|
||||
startRun(input.value);
|
||||
input.value = '';
|
||||
}
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask the agent..."
|
||||
className="w-full px-4 py-2 border rounded"
|
||||
disabled={state.isRunning}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/openapi" className="text-foreground hover:underline">
|
||||
OpenAPI integration
|
||||
</Link>{" "}
|
||||
for rendering forms from API schemas.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
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,112 @@
|
||||
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 { 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();
|
||||
}`}</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, 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>
|
||||
);
|
||||
}`}</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">catalog.prompt()</code> method
|
||||
creates an optimized system prompt that:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Lists all available components and their props</li>
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pass custom rules to tailor AI behavior:
|
||||
</p>
|
||||
<Code lang="typescript">{`const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
'Always use Card components for grouping related content',
|
||||
'Prefer horizontal layouts (Row) for metrics',
|
||||
'Use consistent spacing with padding="md"',
|
||||
],
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/streaming"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
progressive streaming
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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,130 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "@json-render/codegen API | json-render",
|
||||
};
|
||||
|
||||
export default function CodegenApiPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Utilities for generating code from UI trees.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Walk the UI spec depth-first.
|
||||
</p>
|
||||
<Code lang="typescript">{`function traverseSpec(
|
||||
spec: Spec,
|
||||
visitor: SpecVisitor,
|
||||
startKey?: string
|
||||
): void
|
||||
|
||||
interface SpecVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Get all unique component types used in a spec.
|
||||
</p>
|
||||
<Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const components = collectUsedComponents(spec);
|
||||
// Set { 'Card', 'Metric', 'Chart' }`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Get all data paths referenced in props (valuePath, dataPath, bindPath,
|
||||
etc.).
|
||||
</p>
|
||||
<Code lang="typescript">{`function collectDataPaths(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const paths = collectDataPaths(spec);
|
||||
// Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Get all action names used in the spec.
|
||||
</p>
|
||||
<Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const actions = collectActions(spec);
|
||||
// Set { 'submit_form', 'refresh_data' }`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">serializePropValue</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Serialize a single value to a code string.
|
||||
</p>
|
||||
<Code lang="typescript">{`function serializePropValue(
|
||||
value: unknown,
|
||||
options?: SerializeOptions
|
||||
): { value: string; needsBraces: boolean }
|
||||
|
||||
// Examples
|
||||
serializePropValue("hello")
|
||||
// { value: '"hello"', needsBraces: false }
|
||||
|
||||
serializePropValue(42)
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' })
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Serialize a props object to a JSX attributes string.
|
||||
</p>
|
||||
<Code lang="typescript">{`function serializeProps(
|
||||
props: Record<string, unknown>,
|
||||
options?: SerializeOptions
|
||||
): string
|
||||
|
||||
// Example
|
||||
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
|
||||
// 'title="Dashboard" columns={3} disabled'`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">escapeString</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Escape a string for use in code.
|
||||
</p>
|
||||
<Code lang="typescript">{`function escapeString(
|
||||
str: string,
|
||||
quotes?: 'single' | 'double'
|
||||
): string`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">GeneratedFile</h3>
|
||||
<Code lang="typescript">{`interface GeneratedFile {
|
||||
/** File path relative to project root */
|
||||
path: string;
|
||||
/** File contents */
|
||||
content: string;
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
|
||||
<Code lang="typescript">{`interface CodeGenerator {
|
||||
/** Generate files from a UI spec */
|
||||
generate(spec: Spec): GeneratedFile[];
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
|
||||
<Code lang="typescript">{`interface SerializeOptions {
|
||||
/** Quote style for strings */
|
||||
quotes?: 'single' | 'double';
|
||||
/** Indent for objects/arrays */
|
||||
indent?: number;
|
||||
}`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
+150
-173
@@ -1,15 +1,22 @@
|
||||
export const metadata = { title: "@json-render/core API" }
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
# @json-render/core
|
||||
export const metadata = {
|
||||
title: "@json-render/core API | json-render",
|
||||
};
|
||||
|
||||
Core types, schemas, and utilities.
|
||||
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>
|
||||
|
||||
## defineCatalog
|
||||
|
||||
Creates a type-safe catalog definition with schema validation.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Creates a type-safe catalog definition with schema validation.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
function defineCatalog<T extends ZodType>(
|
||||
@@ -21,13 +28,10 @@ function defineCatalog<T extends ZodType>(
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {...},
|
||||
actions: {...},
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
### CatalogConfig
|
||||
|
||||
```typescript
|
||||
interface CatalogConfig {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
|
||||
<Code lang="typescript">{`interface CatalogConfig {
|
||||
components: Record<string, ComponentDefinition>;
|
||||
actions?: Record<string, ActionDefinition>;
|
||||
functions?: Record<string, FunctionDefinition>;
|
||||
@@ -46,15 +50,14 @@ interface ActionDefinition {
|
||||
|
||||
interface FunctionDefinition {
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
### Catalog Instance
|
||||
|
||||
The returned catalog provides methods for AI prompt generation, validation, and schema export:
|
||||
|
||||
```typescript
|
||||
interface Catalog {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The returned catalog provides methods for AI prompt generation,
|
||||
validation, and schema export:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface Catalog {
|
||||
// Data
|
||||
readonly data: CatalogConfig; // The catalog configuration
|
||||
readonly componentNames: string[]; // List of component names
|
||||
@@ -80,13 +83,10 @@ interface SpecValidationResult<T> {
|
||||
success: boolean;
|
||||
data?: T; // Validated spec (if success)
|
||||
error?: z.ZodError; // Validation errors (if failed)
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
### Catalog Methods
|
||||
|
||||
```typescript
|
||||
// Generate AI system prompt
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
|
||||
<Code lang="typescript">{`// Generate AI system prompt
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: ["Always use Card as root element"],
|
||||
});
|
||||
@@ -104,19 +104,22 @@ const schema = catalog.zodSchema();
|
||||
const parsed = schema.safeParse(aiOutput);
|
||||
|
||||
// Export as JSON Schema (for structured outputs)
|
||||
const jsonSchema = catalog.jsonSchema();
|
||||
```
|
||||
const jsonSchema = catalog.jsonSchema();`}</Code>
|
||||
|
||||
## Schema System
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render uses a flexible schema system that defines both the AI
|
||||
output format (spec) and what catalogs must provide. Each renderer
|
||||
package provides its own schema (e.g., @json-render/react exports{" "}
|
||||
<code className="text-foreground">schema</code>).
|
||||
</p>
|
||||
|
||||
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';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema for flat UI element trees. This is exported from
|
||||
@json-render/react.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
// schema defines:
|
||||
@@ -137,15 +140,14 @@ const catalog = defineCatalog(schema, {
|
||||
description: "Submit a form",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
### defineSchema
|
||||
|
||||
Create custom schemas for different output formats (e.g., page-based, block-based).
|
||||
|
||||
```typescript
|
||||
import { defineSchema } from '@json-render/core';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create custom schemas for different output formats (e.g., page-based,
|
||||
block-based).
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineSchema } from '@json-render/core';
|
||||
|
||||
const mySchema = defineSchema((s) => ({
|
||||
// What the AI outputs (spec)
|
||||
@@ -164,15 +166,13 @@ const mySchema = defineSchema((s) => ({
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
```
|
||||
}));`}</Code>
|
||||
|
||||
### Schema Builder API
|
||||
|
||||
The schema builder provides these methods:
|
||||
|
||||
```typescript
|
||||
// Primitive types
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema builder provides these methods:
|
||||
</p>
|
||||
<Code lang="typescript">{`// Primitive types
|
||||
s.string() // String value
|
||||
s.number() // Number value
|
||||
s.boolean() // Boolean value
|
||||
@@ -192,17 +192,15 @@ 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
|
||||
```
|
||||
s.optional() // Mark field as optional`}</Code>
|
||||
|
||||
## Zod Schemas
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-built Zod schemas for common json-render types:
|
||||
</p>
|
||||
|
||||
Pre-built Zod schemas for common json-render types:
|
||||
|
||||
### Dynamic Value Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
DynamicValueSchema, // string | number | boolean | null | { path: string }
|
||||
DynamicStringSchema, // string | { path: string }
|
||||
DynamicNumberSchema, // number | { path: string }
|
||||
@@ -215,13 +213,12 @@ type DynamicValue<T> = T | { path: string };
|
||||
// Example: a prop that can be a literal or bound to data
|
||||
const schema = z.object({
|
||||
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
### Visibility & Logic Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
Visibility & Logic Schemas
|
||||
</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
VisibilityConditionSchema, // Full visibility condition
|
||||
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
|
||||
} from '@json-render/core';
|
||||
@@ -229,39 +226,35 @@ import {
|
||||
// Use in component props that need conditional rendering
|
||||
const schema = z.object({
|
||||
visible: VisibilityConditionSchema.optional(),
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
### Action Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
ActionSchema, // Full action definition
|
||||
ActionConfirmSchema, // Confirmation dialog config
|
||||
ActionOnSuccessSchema, // Success handler config
|
||||
ActionOnErrorSchema, // Error handler config
|
||||
} from '@json-render/core';
|
||||
```
|
||||
} from '@json-render/core';`}</Code>
|
||||
|
||||
### Validation Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
ValidationCheckSchema, // Single validation check
|
||||
ValidationConfigSchema, // Full validation config with checks array
|
||||
} from '@json-render/core';
|
||||
```
|
||||
} from '@json-render/core';`}</Code>
|
||||
|
||||
## SpecStream
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
SpecStream is json-render's streaming format for progressively
|
||||
building specs from JSONL patches.
|
||||
</p>
|
||||
|
||||
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';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
createSpecStreamCompiler
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a streaming compiler that incrementally builds a spec:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
@@ -272,26 +265,21 @@ const { result, newPatches } = compiler.push(chunk);
|
||||
const spec = compiler.getResult();
|
||||
|
||||
// Reset for reuse
|
||||
compiler.reset();
|
||||
```
|
||||
compiler.reset();`}</Code>
|
||||
|
||||
### compileSpecStream
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Compile an entire SpecStream string at once:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
Compile an entire SpecStream string at once:
|
||||
const jsonl = \`{"op":"add","path":"/root","value":{}}
|
||||
{"op":"add","path":"/root/type","value":"Card"}\`;
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":{}}
|
||||
{"op":"add","path":"/root/type","value":"Card"}`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
```
|
||||
|
||||
### Low-Level Utilities
|
||||
|
||||
```typescript
|
||||
import {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
parseSpecStreamLine,
|
||||
applySpecStreamPatch,
|
||||
} from '@json-render/core';
|
||||
@@ -301,15 +289,22 @@ const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||
|
||||
// Apply patch to object (mutates in place)
|
||||
const obj = {};
|
||||
applySpecStreamPatch(obj, patch);
|
||||
```
|
||||
applySpecStreamPatch(obj, patch);`}</Code>
|
||||
|
||||
### SpecStream Types
|
||||
|
||||
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
|
||||
|
||||
```typescript
|
||||
interface SpecStreamLine {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Fully compliant with{" "}
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
RFC 6902
|
||||
</a>
|
||||
:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface SpecStreamLine {
|
||||
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||
path: string;
|
||||
value?: unknown; // Required for add, replace, test
|
||||
@@ -321,49 +316,38 @@ interface SpecStreamCompiler<T> {
|
||||
getResult(): T;
|
||||
getPatches(): SpecStreamLine[];
|
||||
reset(): void;
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## Utility Functions
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
|
||||
|
||||
### Path Utilities
|
||||
|
||||
```typescript
|
||||
import { getByPath, setByPath } from '@json-render/core';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
|
||||
<Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
|
||||
|
||||
// Get value by JSON Pointer path
|
||||
const value = getByPath(data, '/user/name'); // "Alice"
|
||||
|
||||
// Set value by path (mutates object)
|
||||
setByPath(data, '/user/email', 'alice@example.com');
|
||||
```
|
||||
setByPath(data, '/user/email', 'alice@example.com');`}</Code>
|
||||
|
||||
### resolveDynamicValue
|
||||
|
||||
```typescript
|
||||
import { resolveDynamicValue } from '@json-render/core';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
|
||||
<Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
|
||||
|
||||
// Resolve a dynamic value against data
|
||||
const name = resolveDynamicValue("Hello", data); // "Hello"
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
```
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"`}</Code>
|
||||
|
||||
### findFormValue
|
||||
|
||||
```typescript
|
||||
import { findFormValue } from '@json-render/core';
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
|
||||
<Code lang="typescript">{`import { findFormValue } from '@json-render/core';
|
||||
|
||||
// Find form values regardless of path format
|
||||
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
|
||||
const value = findFormValue("name", params, data);
|
||||
```
|
||||
const value = findFormValue("name", params, data);`}</Code>
|
||||
|
||||
## evaluateVisibility
|
||||
|
||||
Evaluates a visibility condition against data and auth state.
|
||||
|
||||
```typescript
|
||||
function evaluateVisibility(
|
||||
<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
|
||||
@@ -379,39 +363,33 @@ type VisibilityCondition =
|
||||
| { gt: [DynamicValue, DynamicValue] }
|
||||
| { gte: [DynamicValue, DynamicValue] }
|
||||
| { lt: [DynamicValue, DynamicValue] }
|
||||
| { lte: [DynamicValue, DynamicValue] };
|
||||
```
|
||||
| { lte: [DynamicValue, DynamicValue] };`}</Code>
|
||||
|
||||
## Types
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
|
||||
|
||||
### UIElement
|
||||
|
||||
```typescript
|
||||
interface UIElement {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
|
||||
<Code lang="typescript">{`interface UIElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition;
|
||||
validation?: ValidationSchema;
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
### Spec (Element Tree)
|
||||
|
||||
```typescript
|
||||
interface Spec {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
|
||||
<Code lang="typescript">{`interface Spec {
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>;
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
<p className="text-sm text-muted-foreground mt-2 mb-4">
|
||||
Elements are stored as a flat map with string keys. The tree structure
|
||||
is built by following the{" "}
|
||||
<code className="text-foreground">children</code> arrays.
|
||||
</p>
|
||||
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||
|
||||
### Action
|
||||
|
||||
```typescript
|
||||
interface Action {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
|
||||
<Code lang="typescript">{`interface Action {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
confirm?: {
|
||||
@@ -421,13 +399,10 @@ interface Action {
|
||||
};
|
||||
onSuccess?: { set: Record<string, unknown> };
|
||||
onError?: { set: Record<string, unknown> };
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
### ValidationSchema
|
||||
|
||||
```typescript
|
||||
interface ValidationSchema {
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
|
||||
<Code lang="typescript">{`interface ValidationSchema {
|
||||
checks: ValidationCheck[];
|
||||
validateOn?: 'change' | 'blur' | 'submit';
|
||||
}
|
||||
@@ -436,5 +411,7 @@ interface ValidationCheck {
|
||||
fn: string;
|
||||
args?: Record<string, unknown>;
|
||||
message: string;
|
||||
}`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
@@ -1,169 +0,0 @@
|
||||
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,143 @@
|
||||
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">defineRegistry</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a type-safe component registry from a catalog. Components receive{" "}
|
||||
<code className="text-foreground">props</code>,{" "}
|
||||
<code className="text-foreground">children</code>,{" "}
|
||||
<code className="text-foreground">onAction</code>, and{" "}
|
||||
<code className="text-foreground">loading</code> with catalog-inferred
|
||||
types.
|
||||
</p>
|
||||
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => <div>{props.title}{children}</div>,
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
// Pass to <Renderer>
|
||||
<Renderer spec={spec} registry={registry} />`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
|
||||
<Code lang="tsx">{`<Renderer
|
||||
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>>;`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
Component Props (via defineRegistry)
|
||||
</h3>
|
||||
<Code lang="tsx">{`interface ComponentContext<P> {
|
||||
props: P; // Typed props from catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
onAction?: (action: { name: string; params?: object }) => void;
|
||||
loading?: boolean;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
|
||||
<Code lang="typescript">{`const {
|
||||
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
|
||||
});`}</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,240 +0,0 @@
|
||||
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,240 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "@json-render/remotion API | json-render",
|
||||
};
|
||||
|
||||
export default function RemotionApiPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">@json-render/remotion</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Remotion video renderer. Turn JSON timeline specs into video
|
||||
compositions.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">schema</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The timeline schema for video specs. Use with{" "}
|
||||
<code className="text-foreground">defineCatalog</code> from core.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema, standardComponentDefinitions } from '@json-render/remotion';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
transitions: standardTransitionDefinitions,
|
||||
effects: standardEffectDefinitions,
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Renderer</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The main composition component that renders timeline specs. Use with
|
||||
Remotion's Player or in a Remotion project.
|
||||
</p>
|
||||
<Code lang="tsx">{`import { Player } from '@remotion/player';
|
||||
import { Renderer } from '@json-render/remotion';
|
||||
|
||||
function VideoPlayer({ spec }) {
|
||||
return (
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec }}
|
||||
durationInFrames={spec.composition.durationInFrames}
|
||||
fps={spec.composition.fps}
|
||||
compositionWidth={spec.composition.width}
|
||||
compositionHeight={spec.composition.height}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Custom Components</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pass custom components to the Renderer:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { Renderer, standardComponents } from '@json-render/remotion';
|
||||
|
||||
const customComponents = {
|
||||
...standardComponents,
|
||||
MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
|
||||
};
|
||||
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec, components: customComponents }}
|
||||
// ...
|
||||
/>`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Standard Components</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-built video components included in the package:
|
||||
</p>
|
||||
<Code lang="typescript">{`import {
|
||||
TitleCard, // Full-screen title with subtitle
|
||||
ImageSlide, // Full-screen image display
|
||||
SplitScreen, // Two-column layout
|
||||
QuoteCard, // Quote with attribution
|
||||
StatCard, // Large statistic display
|
||||
LowerThird, // Name/title overlay
|
||||
TextOverlay, // Centered text overlay
|
||||
TypingText, // Terminal typing animation
|
||||
LogoBug, // Corner logo watermark
|
||||
VideoClip, // Video playback
|
||||
} from '@json-render/remotion';`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">TitleCard Props</h3>
|
||||
<Code lang="typescript">{`{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backgroundColor?: string; // default: "#1a1a1a"
|
||||
textColor?: string; // default: "#ffffff"
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">TypingText Props</h3>
|
||||
<Code lang="typescript">{`{
|
||||
text: string;
|
||||
charsPerSecond?: number; // default: 15
|
||||
showCursor?: boolean; // default: true
|
||||
cursorChar?: string; // default: "|"
|
||||
fontFamily?: string; // default: "monospace"
|
||||
fontSize?: number; // default: 48
|
||||
textColor?: string; // default: "#00ff00"
|
||||
backgroundColor?: string; // default: "#1e1e1e"
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Catalog Definitions</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-built definitions for creating catalogs:
|
||||
</p>
|
||||
<Code lang="typescript">{`import {
|
||||
standardComponentDefinitions, // All standard component definitions
|
||||
standardTransitionDefinitions, // fade, slideLeft, slideRight, etc.
|
||||
standardEffectDefinitions, // kenBurns, pulseGlow, colorShift
|
||||
} from '@json-render/remotion';
|
||||
|
||||
// Use in your catalog
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...standardComponentDefinitions,
|
||||
// Add custom components
|
||||
},
|
||||
transitions: standardTransitionDefinitions,
|
||||
effects: standardEffectDefinitions,
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Hooks & Utilities
|
||||
</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">useTransition</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Calculate transition styles for a clip based on current frame:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { useTransition } from '@json-render/remotion';
|
||||
import { useCurrentFrame } from 'remotion';
|
||||
|
||||
function MyComponent({ clip }) {
|
||||
const frame = useCurrentFrame();
|
||||
const transition = useTransition(clip, frame);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
opacity: transition.opacity,
|
||||
transform: transition.transform,
|
||||
}}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ClipWrapper</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Automatically apply transitions to clip content:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { ClipWrapper } from '@json-render/remotion';
|
||||
|
||||
function MyClip({ clip }) {
|
||||
return (
|
||||
<ClipWrapper clip={clip}>
|
||||
<div>My content with automatic transitions</div>
|
||||
</ClipWrapper>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">TimelineSpec</h3>
|
||||
<Code lang="typescript">{`interface TimelineSpec {
|
||||
composition: {
|
||||
id: string;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
durationInFrames: number;
|
||||
};
|
||||
tracks: Track[];
|
||||
clips: Clip[];
|
||||
audio: {
|
||||
tracks: AudioTrack[];
|
||||
};
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Clip</h3>
|
||||
<Code lang="typescript">{`interface Clip {
|
||||
id: string;
|
||||
trackId: string;
|
||||
component: string;
|
||||
props: Record<string, unknown>;
|
||||
from: number;
|
||||
durationInFrames: number;
|
||||
transitionIn?: {
|
||||
type: string;
|
||||
durationInFrames: number;
|
||||
};
|
||||
transitionOut?: {
|
||||
type: string;
|
||||
durationInFrames: number;
|
||||
};
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">TransitionStyles</h3>
|
||||
<Code lang="typescript">{`interface TransitionStyles {
|
||||
opacity: number;
|
||||
transform: string;
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ComponentRegistry</h3>
|
||||
<Code lang="typescript">{`type ClipComponent = React.ComponentType<{ clip: Clip }>;
|
||||
type ComponentRegistry = Record<string, ClipComponent>;`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Transitions</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Available transition types:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">fade</code> - Opacity fade in/out
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">slideLeft</code> - Slide from right
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">slideRight</code> - Slide from left
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">slideUp</code> - Slide from bottom
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">slideDown</code> - Slide from top
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">zoom</code> - Scale zoom in/out
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">wipe</code> - Horizontal wipe
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
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,126 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Catalog | json-render",
|
||||
};
|
||||
|
||||
export default function CatalogPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Catalog</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
The catalog defines what AI can generate. It's your guardrail.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A catalog is a schema that defines:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Components</strong> — UI elements
|
||||
AI can create (with props and optional slots)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Actions</strong> — Operations AI
|
||||
can trigger
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Functions</strong> — Custom
|
||||
validation or transformation functions
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Define each component with its props schema
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
padding: z.enum(['sm', 'md', 'lg']).nullable(),
|
||||
}),
|
||||
slots: ["default"], // Can contain other components
|
||||
description: "Container card for grouping content",
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(), // JSON Pointer to data
|
||||
format: z.enum(['currency', 'percent', 'number']),
|
||||
}),
|
||||
description: "Display a single metric value",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: 'Submit a form',
|
||||
},
|
||||
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
description: 'Export data in various formats',
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each component in the catalog has:
|
||||
</p>
|
||||
<Code lang="typescript">{`{
|
||||
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
|
||||
slots?: string[], // Named slots for children (e.g., ["default"])
|
||||
description?: string, // Help AI understand when to use it
|
||||
}`}</Code>
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
Use{" "}
|
||||
<code className="text-foreground">slots: ["default"]</code>{" "}
|
||||
for components that can contain children. The slot name corresponds to
|
||||
where child elements are rendered.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Generating AI Prompts
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use the <code className="text-foreground">catalog.prompt()</code> method
|
||||
to generate a system prompt for AI:
|
||||
</p>
|
||||
<Code lang="typescript">{`// Generate a system prompt from your catalog
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
// Or with custom rules for the AI
|
||||
const customPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
"Always use Card as the root element for forms",
|
||||
"Group related inputs in a Stack with direction=vertical",
|
||||
],
|
||||
});
|
||||
|
||||
// Pass this to your AI model as the system prompt`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn how to{" "}
|
||||
<Link href="/docs/registry" className="text-foreground hover:underline">
|
||||
register components
|
||||
</Link>{" "}
|
||||
in your registry.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
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,207 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Changelog | json-render",
|
||||
};
|
||||
|
||||
export default function ChangelogPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Changelog</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Notable changes and updates to json-render.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">v0.4.0</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">February 2026</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
New: Custom Schema System
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create custom output formats with <code>defineSchema</code>. Each
|
||||
renderer now defines its own schema, enabling completely different spec
|
||||
formats for different use cases.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineSchema } from "@json-render/core";
|
||||
|
||||
const mySchema = defineSchema((s) => ({
|
||||
spec: s.object({
|
||||
pages: s.array(s.object({
|
||||
title: s.string(),
|
||||
blocks: s.array(s.ref("catalog.blocks")),
|
||||
})),
|
||||
}),
|
||||
catalog: s.object({
|
||||
blocks: s.map({ props: s.zod(), description: s.string() }),
|
||||
}),
|
||||
}), {
|
||||
promptTemplate: myPromptTemplate,
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">New: Component Slots</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Components can now define which slots they accept. Use{" "}
|
||||
<code>["default"]</code> for regular children, or named slots
|
||||
like <code>["header", "footer"]</code> for more
|
||||
complex layouts.
|
||||
</p>
|
||||
<Code lang="typescript">{`const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
slots: ["default"], // accepts children
|
||||
description: "A card container",
|
||||
},
|
||||
Layout: {
|
||||
props: z.object({}),
|
||||
slots: ["header", "content", "footer"], // named slots
|
||||
description: "Page layout with header, content, footer",
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
New: AI Prompt Generation
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Catalogs now generate AI system prompts automatically with{" "}
|
||||
<code>catalog.prompt()</code>. The prompt includes all component
|
||||
definitions, props schemas, and action descriptions - ensuring the AI
|
||||
only generates valid specs.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
actions: { /* ... */ },
|
||||
});
|
||||
|
||||
// Generate system prompt for AI
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
// Use with any AI SDK
|
||||
const result = await streamText({
|
||||
model: "claude-haiku-4.5",
|
||||
system: systemPrompt,
|
||||
prompt: userMessage,
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
New: @json-render/remotion
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Generate AI-powered videos with Remotion. Define video catalogs, stream
|
||||
timeline specs, and render with the Remotion Player.
|
||||
</p>
|
||||
<Code lang="tsx">{`import { Player } from "@remotion/player";
|
||||
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
transitions: standardTransitionDefinitions,
|
||||
});
|
||||
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec }}
|
||||
durationInFrames={spec.composition.durationInFrames}
|
||||
fps={spec.composition.fps}
|
||||
compositionWidth={spec.composition.width}
|
||||
compositionHeight={spec.composition.height}
|
||||
/>`}</Code>
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
Includes 10 standard video components (TitleCard, TypingText,
|
||||
SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for
|
||||
custom components.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">New: SpecStream</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
SpecStream is json-render's streaming format for progressively
|
||||
building specs from JSONL patches. The new compiler API makes it easy to
|
||||
process streaming AI responses.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createSpecStreamCompiler } from "@json-render/core";
|
||||
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
setSpec(result); // Update UI with partial result`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
Improved: Dashboard Example
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The dashboard example is now a full-featured accounting dashboard with:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Persistent SQLite database with Drizzle ORM</li>
|
||||
<li>RESTful API for customers, invoices, expenses, accounts</li>
|
||||
<li>Draggable widget reordering</li>
|
||||
<li>AI-powered widget generation with streaming</li>
|
||||
<li>Real data binding to database records</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
Improved: Documentation
|
||||
</h3>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Interactive playground for testing specs</li>
|
||||
<li>New guides: Custom Schema, Streaming, Code Export</li>
|
||||
<li>Full API reference for all packages</li>
|
||||
<li>Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Breaking Changes</h3>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<code>UITree</code> type renamed to <code>Spec</code>
|
||||
</li>
|
||||
<li>
|
||||
Schema is now imported from renderer packages (
|
||||
<code>@json-render/react</code>) not core
|
||||
</li>
|
||||
<li>
|
||||
<code>defineCatalog</code> now requires a schema as first argument
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<hr className="my-12 border-border" />
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">v0.3.0</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Internal release with codegen foundations.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
Added <code>@json-render/codegen</code> package (spec traversal and
|
||||
JSX serialization)
|
||||
</li>
|
||||
<li>Configurable AI model via environment variables</li>
|
||||
<li>Documentation improvements and bug fixes</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
Note: Only @json-render/core was published to npm for this release.
|
||||
</p>
|
||||
|
||||
<hr className="my-12 border-border" />
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">v0.2.0</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Initial public release.
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Core catalog and spec types</li>
|
||||
<li>React renderer with contexts for data, actions, visibility</li>
|
||||
<li>AI prompt generation from catalogs</li>
|
||||
<li>Basic streaming support</li>
|
||||
<li>Dashboard example application</li>
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
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,170 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Code Export | json-render",
|
||||
};
|
||||
|
||||
export default function CodeExportPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Code Export</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Export generated UI as standalone code for your framework.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
While json-render is designed for dynamic rendering, you can export
|
||||
generated UI as static code. The code generation is intentionally
|
||||
project-specific so you have full control over:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
|
||||
<li>Component templates (standalone, no json-render dependencies)</li>
|
||||
<li>Package.json and project structure</li>
|
||||
<li>Framework-specific patterns (Next.js, Remix, etc.)</li>
|
||||
<li>How data is passed to components</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Code export is split into two parts:
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
1. @json-render/codegen (utilities)
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Framework-agnostic utilities for building code generators:
|
||||
</p>
|
||||
<Code lang="typescript">{`import {
|
||||
traverseSpec, // Walk the UI spec
|
||||
collectUsedComponents, // Get all component types used
|
||||
collectDataPaths, // Get all data binding paths
|
||||
collectActions, // Get all action names
|
||||
serializeProps, // Convert props to JSX string
|
||||
} from '@json-render/codegen';`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
2. Your Project (generator)
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Custom code generator specific to your project and framework:
|
||||
</p>
|
||||
<Code lang="typescript">{`// lib/codegen/generator.ts
|
||||
import { collectUsedComponents, serializeProps } from '@json-render/codegen';
|
||||
|
||||
export function generateNextJSProject(spec: Spec): GeneratedFile[] {
|
||||
const components = collectUsedComponents(spec);
|
||||
|
||||
return [
|
||||
{ path: 'package.json', content: '...' },
|
||||
{ path: 'app/page.tsx', content: '...' },
|
||||
// ... component files
|
||||
];
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Example: Next.js Export
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
See the dashboard example for a complete implementation that exports:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">package.json</code> - Dependencies
|
||||
and scripts
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">tsconfig.json</code> - TypeScript
|
||||
config
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">next.config.js</code> - Next.js
|
||||
config
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">app/layout.tsx</code> - Root layout
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">app/globals.css</code> - Global
|
||||
styles
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">app/page.tsx</code> - Generated page
|
||||
with data
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">components/ui/*.tsx</code> -
|
||||
Standalone components
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Standalone Components
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The exported components are standalone with no json-render dependencies.
|
||||
They receive data as props instead of using hooks:
|
||||
</p>
|
||||
<Code lang="tsx">{`// Generated component (standalone)
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
valuePath: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, valuePath) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<span>{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">traverseSpec</h3>
|
||||
<Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
|
||||
|
||||
traverseSpec(spec, (element, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + element.type);
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
|
||||
<Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
|
||||
|
||||
const components = collectUsedComponents(spec);
|
||||
// Set { 'Card', 'Metric', 'Chart', 'Table' }
|
||||
|
||||
// Generate only the needed component files
|
||||
for (const component of components) {
|
||||
files.push({
|
||||
path: \`components/ui/\${component.toLowerCase()}.tsx\`,
|
||||
content: componentTemplates[component],
|
||||
});
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
|
||||
<Code lang="typescript">{`import { serializeProps } from '@json-render/codegen';
|
||||
|
||||
const propsStr = serializeProps({
|
||||
title: 'Dashboard',
|
||||
columns: 3,
|
||||
disabled: true,
|
||||
});
|
||||
// 'title="Dashboard" columns={3} disabled'`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Try It</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Run the dashboard example and click "Export Project" to see
|
||||
code generation in action:
|
||||
</p>
|
||||
<Code lang="bash">{`cd examples/dashboard
|
||||
pnpm dev
|
||||
# Open http://localhost:3001
|
||||
# Generate a widget, then click "Export Project"`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
+94
-67
@@ -1,19 +1,37 @@
|
||||
export const metadata = { title: "Custom Schema & Renderer" }
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
# Custom Schema & Renderer
|
||||
export const metadata = {
|
||||
title: "Custom Schema & Renderer | json-render",
|
||||
};
|
||||
|
||||
Build your own schema and renderer with `@json-render/core`.
|
||||
export default function CustomSchemaPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Custom Schema & Renderer</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Build your own schema and renderer with{" "}
|
||||
<code className="text-foreground">@json-render/core</code>.
|
||||
</p>
|
||||
|
||||
## Overview
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/core</code> is
|
||||
schema-agnostic. While{" "}
|
||||
<code className="text-foreground">@json-render/react</code> provides a
|
||||
ready-to-use schema and renderer, you can create your own to match any
|
||||
JSON structure - whether it's a domain-specific format, an existing
|
||||
protocol, or something entirely custom.
|
||||
</p>
|
||||
|
||||
`@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
|
||||
{
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
1. Define Your Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Start by defining the JSON structure your system will use. Here's
|
||||
an example of a simple dashboard schema:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"layout": "grid",
|
||||
"columns": 2,
|
||||
"widgets": [
|
||||
@@ -36,15 +54,15 @@ Start by defining the JSON structure your system will use. Here's an example of
|
||||
"dataKey": "orders"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## 2. Create the Catalog
|
||||
|
||||
Define a catalog that describes your components and validates props:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
2. Create the Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define a catalog that describes your components and validates props:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dashboardCatalog = createCatalog({
|
||||
@@ -84,15 +102,15 @@ export const dashboardCatalog = createCatalog({
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
## 3. Define the Root Schema
|
||||
|
||||
Create a schema for the overall document structure:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
3. Define the Root Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a schema for the overall document structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
|
||||
const WidgetSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -107,15 +125,15 @@ export const DashboardSchema = z.object({
|
||||
});
|
||||
|
||||
export type Dashboard = z.infer<typeof DashboardSchema>;
|
||||
export type Widget = z.infer<typeof WidgetSchema>;
|
||||
```
|
||||
export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
|
||||
|
||||
## 4. Build the Renderer
|
||||
|
||||
Create a renderer that maps your schema to React components:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
4. Build the Renderer
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a renderer that maps your schema to React components:
|
||||
</p>
|
||||
<Code lang="tsx">{`import React from 'react';
|
||||
import { dashboardCatalog } from './catalog';
|
||||
import type { Dashboard, Widget } from './schema';
|
||||
|
||||
@@ -126,7 +144,7 @@ const widgetComponents: Record<string, React.FC<any>> = {
|
||||
<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'}`}>
|
||||
<p className={\`text-sm \${trend === 'up' ? 'text-green-500' : 'text-red-500'}\`}>
|
||||
{trend === 'up' ? '+' : '-'}{change}
|
||||
</p>
|
||||
)}
|
||||
@@ -186,7 +204,7 @@ export function DashboardRenderer({
|
||||
data?: Record<string, any>;
|
||||
}) {
|
||||
const layoutClass = {
|
||||
grid: `grid gap-4 ${spec.columns ? `grid-cols-${spec.columns}` : 'grid-cols-2'}`,
|
||||
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];
|
||||
@@ -196,7 +214,7 @@ export function DashboardRenderer({
|
||||
{spec.widgets.map((widget, index) => {
|
||||
const Component = widgetComponents[widget.type];
|
||||
if (!Component) {
|
||||
console.warn(`Unknown widget type: ${widget.type}`);
|
||||
console.warn(\`Unknown widget type: \${widget.type}\`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -213,15 +231,15 @@ export function DashboardRenderer({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## 5. Generate LLM Prompts
|
||||
|
||||
Use the catalog to generate system prompts for AI:
|
||||
|
||||
```typescript
|
||||
const systemPrompt = dashboardCatalog.prompt({
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
5. Generate LLM Prompts
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use the catalog to generate system prompts for AI:
|
||||
</p>
|
||||
<Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
|
||||
customRules: [
|
||||
'Use metric widgets for single KPI values',
|
||||
'Use chart widgets for time-series data',
|
||||
@@ -235,15 +253,13 @@ const response = await generateText({
|
||||
model: 'gpt-4',
|
||||
system: systemPrompt,
|
||||
prompt: 'Create a sales dashboard with revenue, orders, and a chart',
|
||||
});
|
||||
```
|
||||
});`}</Code>
|
||||
|
||||
## 6. Validate Specs
|
||||
|
||||
Validate incoming specs against your schema:
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Validate incoming specs against your schema:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { validate } from '@json-render/core';
|
||||
|
||||
function validateDashboard(spec: unknown) {
|
||||
// Validate root structure
|
||||
@@ -260,18 +276,15 @@ function validateDashboard(spec: unknown) {
|
||||
dashboardCatalog
|
||||
);
|
||||
if (!result.valid) {
|
||||
errors.push(...result.errors.map(e => `${widget.type}: ${e}`));
|
||||
errors.push(...result.errors.map(e => \`\${widget.type}: \${e}\`));
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
```
|
||||
}`}</Code>
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DashboardRenderer } from './renderer';
|
||||
@@ -300,9 +313,23 @@ export function MyDashboard() {
|
||||
const [spec, setSpec] = useState(initialSpec);
|
||||
|
||||
return <DashboardRenderer spec={spec} data={data} />;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
See how to integrate with{" "}
|
||||
<Link href="/docs/a2ui" className="text-foreground hover:underline">
|
||||
A2UI
|
||||
</Link>{" "}
|
||||
or{" "}
|
||||
<Link
|
||||
href="/docs/adaptive-cards"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Adaptive Cards
|
||||
</Link>{" "}
|
||||
protocols.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
See how to integrate with [A2UI](/docs/a2ui) or [Adaptive Cards](/docs/adaptive-cards) protocols.
|
||||
@@ -1,124 +0,0 @@
|
||||
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,133 @@
|
||||
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,28 +0,0 @@
|
||||
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,43 @@
|
||||
import { PackageInstall } from "@/components/package-install";
|
||||
|
||||
export const metadata = {
|
||||
title: "Installation | json-render",
|
||||
};
|
||||
|
||||
export default function InstallationPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Installation</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Install the core package plus your renderer of choice.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">For React UI</h2>
|
||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">For Remotion Video</h2>
|
||||
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render requires the following peer dependencies:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">react</code> ^19.0.0
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">zod</code> ^4.0.0
|
||||
</li>
|
||||
</ul>
|
||||
<PackageInstall packages="react zod" />
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
To use json-render with AI models, you'll also need the Vercel AI
|
||||
SDK:
|
||||
</p>
|
||||
<PackageInstall packages="ai" />
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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,
|
||||
@@ -18,14 +16,8 @@ export default function DocsLayout({
|
||||
</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 className="flex-1 min-w-0 max-w-2xl">{children}</div>
|
||||
</div>
|
||||
<DocsChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
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,702 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "OpenAPI Integration | json-render",
|
||||
};
|
||||
|
||||
export default function OpenAPIPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use json-render to generate dynamic forms and UIs from{" "}
|
||||
<a
|
||||
href="https://swagger.io/specification/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
OpenAPI/Swagger
|
||||
</a>{" "}
|
||||
schemas.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can
|
||||
support OpenAPI schemas. The examples are illustrative and may require
|
||||
adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
OpenAPI specifications describe your API{"'"}s endpoints, request
|
||||
bodies, and response schemas. By converting OpenAPI schemas to
|
||||
json-render specs, you can:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Automatically generate forms for API endpoints</li>
|
||||
<li>Display API responses with type-aware rendering</li>
|
||||
<li>Keep your UI in sync with your API schema</li>
|
||||
<li>Let AI generate UIs that match your API contracts</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Example OpenAPI Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A typical OpenAPI schema for a request body:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"summary": "Create a new user",
|
||||
"operationId": "createUser",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateUserRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "User's full name",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"age": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 150,
|
||||
"description": "User's age"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "user", "guest"],
|
||||
"default": "user",
|
||||
"description": "User's role"
|
||||
},
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"newsletter": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark", "system"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define an OpenAPI-to-UI Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create components that map to OpenAPI data types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = createCatalog({
|
||||
components: {
|
||||
// Form container
|
||||
Form: {
|
||||
description: 'API form container',
|
||||
props: z.object({
|
||||
operationId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Field components mapped to OpenAPI types
|
||||
StringField: {
|
||||
description: 'String input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
pattern: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
NumberField: {
|
||||
description: 'Number input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.enum(['integer', 'number']).optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
exclusiveMinimum: z.number().optional(),
|
||||
exclusiveMaximum: z.number().optional(),
|
||||
multipleOf: z.number().optional(),
|
||||
defaultValue: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
BooleanField: {
|
||||
description: 'Boolean toggle field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
EnumField: {
|
||||
description: 'Enum selection field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
options: z.array(z.object({
|
||||
value: z.string(),
|
||||
label: z.string().optional(),
|
||||
})),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ArrayField: {
|
||||
description: 'Array of items',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
minItems: z.number().optional(),
|
||||
maxItems: z.number().optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ObjectField: {
|
||||
description: 'Nested object group',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
collapsible: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Response display components
|
||||
ResponseDisplay: {
|
||||
description: 'Displays API response',
|
||||
props: z.object({
|
||||
status: z.number(),
|
||||
statusText: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
SchemaTable: {
|
||||
description: 'Displays data matching a schema',
|
||||
props: z.object({
|
||||
schema: z.string(),
|
||||
data: z.array(z.record(z.unknown())),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit: {
|
||||
description: 'Submit form to API endpoint',
|
||||
params: z.object({
|
||||
operationId: z.string(),
|
||||
}),
|
||||
},
|
||||
reset: {
|
||||
description: 'Reset form to defaults',
|
||||
params: z.object({}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Convert OpenAPI Schema to Spec
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Transform OpenAPI schemas into json-render specs:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
enum?: string[];
|
||||
properties?: Record<string, OpenAPISchema>;
|
||||
items?: OpenAPISchema;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface SpecElement {
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children: string[];
|
||||
}
|
||||
|
||||
function schemaToSpec(
|
||||
schema: OpenAPISchema,
|
||||
name: string,
|
||||
required: string[] = [],
|
||||
parentKey: string = '',
|
||||
elements: Map<string, SpecElement> = new Map(),
|
||||
): string {
|
||||
const key = parentKey ? \`\${parentKey}-\${name}\` : name;
|
||||
const isRequired = required.includes(name);
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
|
||||
|
||||
if (schema.enum) {
|
||||
elements.set(key, {
|
||||
type: 'EnumField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
options: schema.enum.map(v => ({ value: v, label: v })),
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
} else if (schema.type === 'string') {
|
||||
elements.set(key, {
|
||||
type: 'StringField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
format: schema.format || 'text',
|
||||
minLength: schema.minLength,
|
||||
maxLength: schema.maxLength,
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
elements.set(key, {
|
||||
type: 'NumberField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
type: schema.type,
|
||||
minimum: schema.minimum,
|
||||
maximum: schema.maximum,
|
||||
defaultValue: schema.default as number,
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
} else if (schema.type === 'boolean') {
|
||||
elements.set(key, {
|
||||
type: 'BooleanField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
defaultValue: schema.default as boolean,
|
||||
},
|
||||
children: [],
|
||||
});
|
||||
} else if (schema.type === 'array' && schema.items) {
|
||||
const childKeys: string[] = [];
|
||||
const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
|
||||
childKeys.push(itemKey);
|
||||
|
||||
elements.set(key, {
|
||||
type: 'ArrayField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
});
|
||||
} else if (schema.type === 'object' && schema.properties) {
|
||||
const childKeys: string[] = [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
const childKey = schemaToSpec(
|
||||
propSchema,
|
||||
propName,
|
||||
schema.required || [],
|
||||
key,
|
||||
elements,
|
||||
);
|
||||
childKeys.push(childKey);
|
||||
}
|
||||
|
||||
elements.set(key, {
|
||||
type: 'ObjectField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
});
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
// Convert full OpenAPI operation to spec
|
||||
export function operationToSpec(
|
||||
operationId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
schema: OpenAPISchema,
|
||||
title?: string,
|
||||
description?: string,
|
||||
) {
|
||||
const elements = new Map<string, SpecElement>();
|
||||
const rootKey = 'form';
|
||||
const childKeys: string[] = [];
|
||||
|
||||
if (schema.properties) {
|
||||
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
||||
const childKey = schemaToSpec(
|
||||
propSchema,
|
||||
name,
|
||||
schema.required || [],
|
||||
rootKey,
|
||||
elements,
|
||||
);
|
||||
childKeys.push(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
elements.set(rootKey, {
|
||||
type: 'Form',
|
||||
props: {
|
||||
operationId,
|
||||
endpoint: path,
|
||||
method: method.toUpperCase(),
|
||||
title,
|
||||
description,
|
||||
},
|
||||
children: childKeys,
|
||||
});
|
||||
|
||||
return {
|
||||
root: rootKey,
|
||||
elements: Object.fromEntries(elements),
|
||||
};
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Build an OpenAPI Form Renderer
|
||||
</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface FieldProps {
|
||||
name: string;
|
||||
value: unknown;
|
||||
onChange: (name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
const fields: Record<string, React.FC<any>> = {
|
||||
StringField: ({ name, label, description, required, format, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<input
|
||||
type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as number) ?? ''}
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
BooleanField: ({ name, label, description, value, onChange }) => (
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(name, e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={name} className="text-sm font-medium">{label}</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
EnumField: ({ name, label, description, required, options, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
required={required}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{options?.map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label || opt.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
|
||||
ObjectField: ({ name, label, description, children }) => (
|
||||
<fieldset className="border rounded p-4 space-y-4">
|
||||
<legend className="text-sm font-medium px-2">{label}</legend>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</fieldset>
|
||||
),
|
||||
|
||||
Form: ({ title, description, endpoint, method, children, onSubmit }) => (
|
||||
<form
|
||||
className="space-y-4 max-w-md"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
{title && <h2 className="text-lg font-semibold">{title}</h2>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
|
||||
>
|
||||
{method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
),
|
||||
};
|
||||
|
||||
interface OpenAPIFormProps {
|
||||
spec: {
|
||||
root: string;
|
||||
elements: Record<string, any>;
|
||||
};
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleChange = (name: string, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
function renderElement(key: string): React.ReactNode {
|
||||
const element = spec.elements[key];
|
||||
if (!element) return null;
|
||||
|
||||
const Field = fields[element.type];
|
||||
if (!Field) return null;
|
||||
|
||||
const children = element.children?.map(renderElement);
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
{...element.props}
|
||||
value={formData[element.props.name]}
|
||||
onChange={handleChange}
|
||||
onSubmit={() => onSubmit(formData)}
|
||||
>
|
||||
{children}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{renderElement(spec.root)}</>;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import { OpenAPIForm } from './openapi-form';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
// Your OpenAPI schema (typically loaded from your API)
|
||||
const createUserSchema = {
|
||||
type: 'object',
|
||||
required: ['email', 'name'],
|
||||
properties: {
|
||||
name: { type: 'string', description: "User's full name" },
|
||||
email: { type: 'string', format: 'email', description: "User's email" },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to spec
|
||||
const spec = operationToSpec(
|
||||
'createUser',
|
||||
'POST',
|
||||
'/api/users',
|
||||
createUserSchema,
|
||||
'Create User',
|
||||
'Add a new user to the system',
|
||||
);
|
||||
|
||||
export function CreateUserForm() {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('User created!');
|
||||
}
|
||||
};
|
||||
|
||||
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Auto-generating from OpenAPI Document
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Load and parse an OpenAPI document to generate forms for all operations:
|
||||
</p>
|
||||
<Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
interface OpenAPIDocument {
|
||||
paths: Record<string, Record<string, {
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
requestBody?: {
|
||||
content?: {
|
||||
'application/json'?: {
|
||||
schema?: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>>;
|
||||
components?: {
|
||||
schemas?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadOpenAPISpecs(specUrl: string) {
|
||||
const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
|
||||
const specs: Record<string, any> = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(api.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
|
||||
|
||||
const schema = operation.requestBody.content['application/json'].schema;
|
||||
const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
|
||||
|
||||
specs[operationId] = operationToSpec(
|
||||
operationId,
|
||||
method,
|
||||
path,
|
||||
schema,
|
||||
operation.summary,
|
||||
operation.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
|
||||
// specs.createUser, specs.updateUser, etc.`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/streaming"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
streaming
|
||||
</Link>{" "}
|
||||
for progressive UI rendering.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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,67 @@
|
||||
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,169 +0,0 @@
|
||||
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,220 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Quick Start | json-render",
|
||||
};
|
||||
|
||||
export default function QuickStartPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Quick Start</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Get up and running with json-render in 5 minutes.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
1. Define your catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a catalog that defines what components AI can use:
|
||||
</p>
|
||||
<Code lang="typescript">{`// lib/catalog.ts
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Container card with optional title",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
action: z.string().nullable(),
|
||||
}),
|
||||
description: "Clickable button that triggers an action",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
description: "Text paragraph",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: {
|
||||
params: z.object({ formId: z.string() }),
|
||||
description: "Submit a form",
|
||||
},
|
||||
navigate: {
|
||||
params: z.object({ url: z.string() }),
|
||||
description: "Navigate to a URL",
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
2. Define your components
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use <code className="text-foreground">defineRegistry</code> to map
|
||||
catalog types to React components. Each component receives type-safe{" "}
|
||||
<code className="text-foreground">props</code>,{" "}
|
||||
<code className="text-foreground">children</code>, and{" "}
|
||||
<code className="text-foreground">onAction</code>:
|
||||
</p>
|
||||
<Code lang="tsx">{`// lib/registry.tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
import { catalog } from './catalog';
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<div className="p-4 border rounded-lg">
|
||||
<h2 className="font-bold">{props.title}</h2>
|
||||
{props.description && (
|
||||
<p className="text-gray-600">{props.description}</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({ props, onAction }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||
onClick={() => onAction?.({ name: props.action })}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
Text: ({ props }) => (
|
||||
<p>{props.content}</p>
|
||||
),
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
3. Create an API route
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Set up a streaming API route for AI generation:
|
||||
</p>
|
||||
<Code lang="typescript">{`// app/api/generate/route.ts
|
||||
import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
// Generate system prompt from catalog
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: systemPrompt,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use providers and the <code className="text-foreground">Renderer</code>{" "}
|
||||
with your registry to display AI-generated UI:
|
||||
</p>
|
||||
<Code lang="tsx">{`// app/page.tsx
|
||||
'use client';
|
||||
|
||||
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
|
||||
import { registry } from '@/lib/registry';
|
||||
|
||||
export default function Page() {
|
||||
const { spec, isStreaming, send } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
send(formData.get('prompt') as string);
|
||||
};
|
||||
|
||||
return (
|
||||
<DataProvider initialData={{}}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={{
|
||||
submit: (params) => console.log('Submit:', params),
|
||||
navigate: (params) => console.log('Navigate:', params),
|
||||
}}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
name="prompt"
|
||||
placeholder="Describe what you want..."
|
||||
className="border p-2 rounded"
|
||||
/>
|
||||
<button type="submit" disabled={isStreaming}>
|
||||
Generate
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-8">
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
|
||||
<li>
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/catalog"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
catalogs
|
||||
</Link>{" "}
|
||||
in depth
|
||||
</li>
|
||||
<li>
|
||||
Explore{" "}
|
||||
<Link
|
||||
href="/docs/data-binding"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
data binding
|
||||
</Link>{" "}
|
||||
for dynamic values
|
||||
</li>
|
||||
<li>
|
||||
Add{" "}
|
||||
<Link
|
||||
href="/docs/actions"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
actions
|
||||
</Link>{" "}
|
||||
for interactivity
|
||||
</li>
|
||||
<li>
|
||||
Implement{" "}
|
||||
<Link
|
||||
href="/docs/visibility"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
conditional visibility
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
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,248 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Registry | json-render",
|
||||
};
|
||||
|
||||
export default function RegistryPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Registry</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Register React components and action handlers to bring your catalog to
|
||||
life.
|
||||
</p>
|
||||
|
||||
{/* defineRegistry */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use <code>defineRegistry</code> to create a type-safe registry from your
|
||||
catalog. Pass your components, actions, or both in a single call:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
|
||||
import { myCatalog } from './catalog';
|
||||
|
||||
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<div className="card">
|
||||
<h2>{props.title}</h2>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit_form: async (params, setData) => {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const result = await res.json();
|
||||
setData((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, \`export.\${params.format}\`);
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
The returned object contains:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
|
||||
<li>
|
||||
<code>registry</code> - component registry for{" "}
|
||||
<code>{"<Renderer />"}</code>
|
||||
</li>
|
||||
<li>
|
||||
<code>handlers</code> - factory for ActionProvider-compatible handlers
|
||||
</li>
|
||||
<li>
|
||||
<code>executeAction</code> - imperative action dispatch (for use
|
||||
outside the React tree)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* Component Props */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each component in the registry receives a <code>ComponentContext</code>{" "}
|
||||
object:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
onAction?: (action: ActionTrigger) => void; // Dispatch an action
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
}`}</Code>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-4 mb-4">
|
||||
Props are automatically inferred from your catalog, so{" "}
|
||||
<code>props.title</code> is typed as <code>string</code> if your catalog
|
||||
defines it that way.
|
||||
</p>
|
||||
|
||||
{/* Action Handlers */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Instead of AI generating arbitrary code, it declares <em>intent</em> by
|
||||
name. Your application provides the implementation. This is a core
|
||||
guardrail.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Defining Actions</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define available actions in your catalog:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: 'Submit a form',
|
||||
},
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
},
|
||||
navigate: {
|
||||
params: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">
|
||||
Implementing Action Handlers
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Action handlers receive <code>(params, setData, data)</code> and are
|
||||
defined inside <code>defineRegistry</code>:
|
||||
</p>
|
||||
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
actions: {
|
||||
submit_form: async (params, setData) => {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ formId: params.formId }),
|
||||
});
|
||||
const result = await response.json();
|
||||
setData((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, \`export.\${params.format}\`);
|
||||
},
|
||||
|
||||
navigate: (params) => {
|
||||
window.location.href = params.url;
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
{/* Using Data Binding */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use hooks inside your registry components to read and write data:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useData } from '@json-render/react';
|
||||
import { getByPath } from '@json-render/core';
|
||||
|
||||
// Inside defineRegistry components:
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const { data } = useData();
|
||||
const value = getByPath(data, props.valuePath);
|
||||
|
||||
return (
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
TextField: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const value = getByPath(data, props.valuePath) as string;
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
},`}</Code>
|
||||
|
||||
{/* Renderer Section */}
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Wire everything together with providers and the{" "}
|
||||
<code>{"<Renderer />"}</code> component:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
|
||||
import {
|
||||
Renderer,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, data, setData }) {
|
||||
const dataRef = useRef(data);
|
||||
const setDataRef = useRef(setData);
|
||||
dataRef.current = data;
|
||||
setDataRef.current = setData;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setDataRef.current, () => dataRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataProvider initialData={data}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/data-binding"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
data binding
|
||||
</Link>{" "}
|
||||
for dynamic values.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
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,220 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Schemas | json-render",
|
||||
};
|
||||
|
||||
export default function SchemasPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Schemas</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Schemas define the structure and validation rules for your UI specs.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A schema defines the JSON structure that describes your UI. It includes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Element structure</strong> — How
|
||||
components are nested and referenced
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Property types</strong> — What
|
||||
props each component accepts
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Data binding syntax</strong> — How
|
||||
to reference dynamic data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Action format</strong> — How user
|
||||
interactions are defined
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Schema-Agnostic by Design
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render can work with any JSON schema.{" "}
|
||||
<code className="text-foreground">@json-render/core</code> provides the
|
||||
primitives to define catalogs and renderers for any format:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">@json-render/react</strong> — The
|
||||
built-in flat element tree schema
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">
|
||||
<Link href="/docs/a2ui" className="hover:underline">
|
||||
A2UI
|
||||
</Link>
|
||||
</strong>{" "}
|
||||
— Google{"'"}s Agent-to-User Interaction protocol
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">
|
||||
<Link href="/docs/adaptive-cards" className="hover:underline">
|
||||
Adaptive Cards
|
||||
</Link>
|
||||
</strong>{" "}
|
||||
— Microsoft{"'"}s platform-agnostic UI format
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
|
||||
Agent User Interaction Protocol
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">OpenAPI/Swagger</strong> — API
|
||||
documentation schemas for dynamic forms
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Custom schemas</strong> — Design
|
||||
your own format tailored to your domain
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
See the{" "}
|
||||
<Link
|
||||
href="/docs/custom-schema"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Custom Schema guide
|
||||
</Link>{" "}
|
||||
to learn how to implement support for any schema.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/react</code> uses a flat
|
||||
element tree schema with a root key and elements map:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
In the built-in schema, each element in the elements map has this
|
||||
structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface Element {
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Reference dynamic data using the{" "}
|
||||
<code className="text-foreground">$data</code> prefix in props:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
},
|
||||
"children": []
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Actions are defined in the catalog and referenced from components. The
|
||||
renderer handles action execution:
|
||||
</p>
|
||||
<Code lang="typescript">{`// In your catalog
|
||||
actions: {
|
||||
navigate: {
|
||||
params: z.object({ url: z.string() }),
|
||||
description: 'Navigate to a URL',
|
||||
},
|
||||
apiCall: {
|
||||
params: z.object({
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
|
||||
}),
|
||||
description: 'Make an API request',
|
||||
},
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/core</code> is
|
||||
schema-agnostic. You can define any JSON structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
|
||||
// Define your own element schema
|
||||
const MyElementSchema = z.object({
|
||||
component: z.string(),
|
||||
settings: z.record(z.unknown()),
|
||||
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
|
||||
});
|
||||
|
||||
// Define your own data binding format
|
||||
const BoundValue = z.object({
|
||||
literal: z.string().optional(),
|
||||
path: z.string().optional(), // e.g., "/users/0/name"
|
||||
});
|
||||
|
||||
// Define your own action format
|
||||
const ActionSchema = z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.unknown()).optional(),
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema and catalog work together but serve different purposes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Schema</strong> — Defines the JSON
|
||||
structure (how elements are organized)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Catalog</strong> — Defines
|
||||
available components and their props (what can be used)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema is the grammar; the catalog is the vocabulary.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/specs" className="text-foreground hover:underline">
|
||||
specs
|
||||
</Link>{" "}
|
||||
— the actual JSON documents that describe your UI.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
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,319 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Specs | json-render",
|
||||
};
|
||||
|
||||
export default function SpecsPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Specs</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
A spec is a JSON document that describes your UI.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A spec (specification) is the actual JSON that describes a UI. It
|
||||
conforms to a{" "}
|
||||
<Link href="/docs/schemas" className="text-foreground hover:underline">
|
||||
schema
|
||||
</Link>{" "}
|
||||
and uses components from a{" "}
|
||||
<Link href="/docs/catalog" className="text-foreground hover:underline">
|
||||
catalog
|
||||
</Link>
|
||||
. Specs can be:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Generated by AI in real-time</li>
|
||||
<li>Stored in a database</li>
|
||||
<li>Streamed progressively from a server</li>
|
||||
<li>Hand-authored as JSON files</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render is schema-agnostic — your specs can follow any JSON
|
||||
structure you choose.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A basic spec using the{" "}
|
||||
<code className="text-foreground">@json-render/react</code> schema. Note
|
||||
the flat structure with a <code className="text-foreground">root</code>{" "}
|
||||
key and <code className="text-foreground">elements</code> map:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Welcome" },
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A more complex spec with multiple nested elements:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A high-level spec using semantic blocks for page layouts:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "page",
|
||||
"elements": {
|
||||
"page": {
|
||||
"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": []
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Every spec has a <code className="text-foreground">root</code> key
|
||||
pointing to the entry element, and an{" "}
|
||||
<code className="text-foreground">elements</code> map containing all
|
||||
elements:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "My Card" },
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": { ... }
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each element in the map has a consistent shape:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "ComponentName",
|
||||
"props": { "label": "Hello" },
|
||||
"children": ["child-1", "child-2"]
|
||||
}`}</Code>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">type</code> — Component type from
|
||||
your catalog
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">props</code> — Component properties
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">children</code> — Array of child
|
||||
element keys
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Props can reference data using{" "}
|
||||
<code className="text-foreground">$data</code> paths:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
},
|
||||
"children": []
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control when elements appear using the{" "}
|
||||
<code className="text-foreground">visible</code> property:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "Alert",
|
||||
"props": {
|
||||
"message": "You have unsaved changes"
|
||||
},
|
||||
"children": [],
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
|
||||
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
|
||||
|
||||
function MyApp({ spec, data }) {
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
registry={registry}
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
|
||||
<Code lang="typescript">{`import { validate } from '@json-render/core';
|
||||
|
||||
const result = validate(spec, catalog);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.errors);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Specs can be streamed incrementally for progressive rendering:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Specs can come from various sources:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">AI Generation</strong> — LLMs
|
||||
generate specs based on prompts and catalog
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Database</strong> — Store specs as
|
||||
JSON and load dynamically
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">API Response</strong> — Server
|
||||
returns specs based on user/context
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Static Files</strong> — Pre-built
|
||||
specs for known UI patterns
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/catalog" className="text-foreground hover:underline">
|
||||
catalogs
|
||||
</Link>{" "}
|
||||
— the vocabulary of components available in your specs.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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,201 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Streaming | json-render",
|
||||
};
|
||||
|
||||
export default function StreamingPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Streaming</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Progressively render UI as AI generates it.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream Format</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
|
||||
format where each line is a JSON patch operation that progressively
|
||||
builds your spec:
|
||||
</p>
|
||||
<Code lang="json">{`{"op":"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"}}}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The hook handles parsing and state management:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
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
|
||||
});
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Patch Operations (RFC 6902)
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
SpecStream uses{" "}
|
||||
<a
|
||||
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
RFC 6902 JSON Patch
|
||||
</a>{" "}
|
||||
operations:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">add</code> — Add a value at a path
|
||||
(creates or replaces for objects, inserts for arrays)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">remove</code> — Remove the value at
|
||||
a path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">replace</code> — Replace an existing
|
||||
value at a path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">move</code> — Move a value from one
|
||||
path to another (requires{" "}
|
||||
<code className="text-foreground">from</code>)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">copy</code> — Copy a value from one
|
||||
path to another (requires{" "}
|
||||
<code className="text-foreground">from</code>)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">test</code> — Assert that a value at
|
||||
a path equals the given value
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Paths use a key-based format for elements:
|
||||
</p>
|
||||
<Code lang="bash">{`/root -> Root element
|
||||
/root/children -> Children of root
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/children -> Children of card-1`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ensure your API route streams properly:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: catalog.prompt(),
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Return as a streaming response
|
||||
return result.toTextStreamResponse();
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Progressive Rendering
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The Renderer automatically updates as the spec changes:
|
||||
</p>
|
||||
<Code lang="tsx">{`function App() {
|
||||
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isStreaming && <LoadingIndicator />}
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Calling <code className="text-foreground">send</code> again
|
||||
automatically aborts the previous request. Use{" "}
|
||||
<code className="text-foreground">clear</code> to reset the spec and
|
||||
error state:
|
||||
</p>
|
||||
<Code lang="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>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Low-Level SpecStream API
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
For custom streaming implementations, use the SpecStream compiler
|
||||
directly:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const { result, newPatches } = compiler.push(value);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = \`{"op":"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" } } }`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
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,189 @@
|
||||
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">{`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',
|
||||
},
|
||||
},
|
||||
});`}</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({ 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>
|
||||
);
|
||||
}`}</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,141 +0,0 @@
|
||||
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,148 @@
|
||||
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';
|
||||
|
||||
// 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>;
|
||||
}`}</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>
|
||||
);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export const catalog = createCatalog({
|
||||
"chart": {
|
||||
"type": "Chart",
|
||||
"props": {
|
||||
"statePath": "analytics/salesByRegion"
|
||||
"dataPath": "analytics/salesByRegion"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export default function Page() {
|
||||
valuePath="analytics/revenue"
|
||||
format="currency"
|
||||
/>
|
||||
<Chart data={data} statePath="analytics/salesByRegion" />
|
||||
<Chart data={data} dataPath="analytics/salesByRegion" />
|
||||
</Card>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
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();
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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,19 +1,15 @@
|
||||
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";
|
||||
import { playgroundCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
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.",
|
||||
"For forms: Card should be the root element, not wrapped in a centering Stack",
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
|
||||
"NEVER use page background colors (bg-gray-50) - container has its own background",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -48,12 +44,31 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const { prompt, context } = await req.json();
|
||||
const previousSpec = context?.previousSpec;
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: context?.previousSpec,
|
||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||
});
|
||||
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
||||
|
||||
// Build the user prompt, including previous tree for iteration
|
||||
let userPrompt = sanitizedPrompt;
|
||||
if (
|
||||
previousSpec &&
|
||||
previousSpec.root &&
|
||||
Object.keys(previousSpec.elements || {}).length > 0
|
||||
) {
|
||||
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
|
||||
${JSON.stringify(previousSpec, null, 2)}
|
||||
|
||||
USER REQUEST: ${sanitizedPrompt}
|
||||
|
||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
||||
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
|
||||
- To remove an element: {"op":"remove","path":"/elements/old-key"}
|
||||
- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
|
||||
- To add children: update the parent element with new children array
|
||||
|
||||
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
@@ -62,33 +77,5 @@ export async function POST(req: Request) {
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
// 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" },
|
||||
});
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
|
||||
@@ -4,7 +4,6 @@ import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
@@ -18,7 +17,7 @@ const geistMono = localFont({
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://json-render.dev"),
|
||||
title: {
|
||||
default: `json-render | ${PAGE_TITLES[""]}`,
|
||||
default: "json-render | AI-generated UI with guardrails",
|
||||
template: "%s | json-render",
|
||||
},
|
||||
description:
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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,6 +1,44 @@
|
||||
import { getPageTitle, renderOgImage } from "./og-image";
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export async function GET() {
|
||||
const title = getPageTitle("")!;
|
||||
return renderOgImage(title);
|
||||
export async function GET(request: Request) {
|
||||
const geist = await fetch(new URL("/Geist-Regular.ttf", request.url)).then(
|
||||
(res) => res.arrayBuffer(),
|
||||
);
|
||||
|
||||
return new ImageResponse(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "black",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 144,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
letterSpacing: "-0.02em",
|
||||
}}
|
||||
>
|
||||
json-render
|
||||
</span>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geist,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Playground } from "@/components/playground";
|
||||
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
|
||||
export const metadata = {
|
||||
title: PAGE_TITLES["playground"],
|
||||
title: "Playground | json-render",
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
+29
-296
@@ -14,8 +14,7 @@ import { toast } from "sonner";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { PlaygroundRenderer } from "@/lib/renderer";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
|
||||
@@ -138,7 +137,7 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
|
||||
type Mode = "simulation" | "interactive";
|
||||
type Phase = "typing" | "streaming" | "complete";
|
||||
type Tab = "stream" | "json" | "nested" | "catalog";
|
||||
type Tab = "stream" | "json";
|
||||
type RenderView = "dynamic" | "static";
|
||||
|
||||
interface DemoProps {
|
||||
@@ -146,51 +145,6 @@ interface DemoProps {
|
||||
skipSimulation?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat Spec into a nested tree structure that is easier for humans
|
||||
* to read. Children keys are resolved recursively into inline objects.
|
||||
*/
|
||||
function specToNested(spec: Spec): Record<string, unknown> {
|
||||
function resolve(key: string): Record<string, unknown> {
|
||||
const el = spec.elements[key];
|
||||
if (!el) return { _key: key, _missing: true };
|
||||
|
||||
const node: Record<string, unknown> = { type: el.type };
|
||||
|
||||
if (el.props && Object.keys(el.props).length > 0) {
|
||||
node.props = el.props;
|
||||
}
|
||||
|
||||
if (el.visible !== undefined) {
|
||||
node.visible = el.visible;
|
||||
}
|
||||
|
||||
if (el.on && Object.keys(el.on).length > 0) {
|
||||
node.on = el.on;
|
||||
}
|
||||
|
||||
if (el.repeat) {
|
||||
node.repeat = el.repeat;
|
||||
}
|
||||
|
||||
if (el.children && el.children.length > 0) {
|
||||
node.children = el.children.map(resolve);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (spec.state && Object.keys(spec.state).length > 0) {
|
||||
result.state = spec.state;
|
||||
}
|
||||
|
||||
result.elements = resolve(spec.root);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form with email and password",
|
||||
"Build a feedback form with rating stars",
|
||||
@@ -225,92 +179,6 @@ export function Demo({
|
||||
new Set(),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [catalogSection, setCatalogSection] = useState<
|
||||
"components" | "actions"
|
||||
>("components");
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
|
||||
// Disable body scroll when any modal is open
|
||||
useEffect(() => {
|
||||
@@ -330,7 +198,6 @@ export function Demo({
|
||||
isStreaming,
|
||||
send,
|
||||
clear,
|
||||
rawLines: apiRawLines,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => {
|
||||
@@ -403,12 +270,25 @@ export function Demo({
|
||||
return () => clearInterval(interval);
|
||||
}, [mode, phase]);
|
||||
|
||||
// Track stream lines from real API (use raw JSONL patch lines)
|
||||
// Track stream lines from real API
|
||||
useEffect(() => {
|
||||
if (mode === "interactive" && apiRawLines.length > 0) {
|
||||
setStreamLines(apiRawLines);
|
||||
if (mode === "interactive" && apiSpec) {
|
||||
// Convert tree to stream line for display
|
||||
const streamLine = JSON.stringify({ tree: apiSpec });
|
||||
if (
|
||||
!streamLines.includes(streamLine) &&
|
||||
Object.keys(apiSpec.elements).length > 0
|
||||
) {
|
||||
setStreamLines((prev) => {
|
||||
const lastLine = prev[prev.length - 1];
|
||||
if (lastLine !== streamLine) {
|
||||
return [...prev, streamLine];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [mode, apiRawLines]);
|
||||
}, [mode, apiSpec, streamLines]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!userPrompt.trim() || isStreaming) return;
|
||||
@@ -420,11 +300,6 @@ export function Demo({
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
|
||||
const nestedCode = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) return "// waiting...";
|
||||
return JSON.stringify(specToNested(currentTree), null, 2);
|
||||
}, [currentTree]);
|
||||
|
||||
// Generate all export files for Next.js project
|
||||
const exportedFiles = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) {
|
||||
@@ -1025,13 +900,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
setMode("interactive");
|
||||
setPhase("complete");
|
||||
setUserPrompt(prompt);
|
||||
setTimeout(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(prompt.length, prompt.length);
|
||||
}
|
||||
}, 0);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -1160,7 +1029,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
{/* Tabbed code/stream/json panel */}
|
||||
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
|
||||
<div className="flex items-center gap-4 mb-2 h-6 shrink-0">
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
{(["json", "stream"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -1177,20 +1046,14 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
<div
|
||||
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
|
||||
>
|
||||
{activeTab !== "catalog" && (
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton
|
||||
text={
|
||||
activeTab === "stream"
|
||||
? streamLines.join("\n")
|
||||
: activeTab === "nested"
|
||||
? nestedCode
|
||||
: jsonCode
|
||||
}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<CopyButton
|
||||
text={
|
||||
activeTab === "stream" ? streamLines.join("\n") : jsonCode
|
||||
}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
|
||||
>
|
||||
@@ -1226,136 +1089,6 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
hideCopyButton
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-auto ${activeTab === "nested" ? "" : "hidden"}`}
|
||||
>
|
||||
<CodeBlock
|
||||
code={nestedCode}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={`overflow-auto ${activeTab === "catalog" ? "" : "hidden"}`}
|
||||
>
|
||||
<div className="h-full flex flex-col text-sm font-sans">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "components",
|
||||
label: `components (${catalogData.components.length})`,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: `actions (${catalogData.actions.length})`,
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setCatalogSection(key)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
catalogSection === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{catalogSection === "components" ? (
|
||||
<div className="space-y-3">
|
||||
{catalogData.components.map((comp) => (
|
||||
<div
|
||||
key={comp.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{comp.name}
|
||||
</span>
|
||||
{comp.slots.length > 0 && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
slots: {comp.slots.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{comp.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{comp.description}
|
||||
</p>
|
||||
)}
|
||||
{comp.props.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{comp.props.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{comp.events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{comp.events.map((e) => (
|
||||
<span
|
||||
key={e}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
on.{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{catalogData.actions.map((action) => (
|
||||
<div
|
||||
key={action.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{action.name}
|
||||
</span>
|
||||
{action.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
{action.params.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{action.params.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +100,7 @@ export function Header() {
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
<span>10.1k</span>
|
||||
<span>10k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
+105
-703
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useUIStream, type TokenUsage } from "@json-render/react";
|
||||
import { useUIStream } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||
import { toast } from "sonner";
|
||||
@@ -15,72 +14,17 @@ import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
import { PlaygroundRenderer } from "@/lib/renderer";
|
||||
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type Tab = "json" | "stream";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobileView =
|
||||
| "json"
|
||||
| "nested"
|
||||
| "stream"
|
||||
| "catalog"
|
||||
| "preview"
|
||||
| "generated-code";
|
||||
type MobilePane = "chat" | "code" | "preview";
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
prompt: string;
|
||||
tree: Spec | null;
|
||||
status: "generating" | "complete" | "error";
|
||||
usage: TokenUsage | null;
|
||||
rawLines: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a flat Spec into a nested tree structure that is easier for humans
|
||||
* to read. Children keys are resolved recursively into inline objects.
|
||||
*/
|
||||
function specToNested(spec: Spec): Record<string, unknown> {
|
||||
function resolve(key: string): Record<string, unknown> {
|
||||
const el = spec.elements[key];
|
||||
if (!el) return { _key: key, _missing: true };
|
||||
|
||||
const node: Record<string, unknown> = { type: el.type };
|
||||
|
||||
if (el.props && Object.keys(el.props).length > 0) {
|
||||
node.props = el.props;
|
||||
}
|
||||
|
||||
if (el.visible !== undefined) {
|
||||
node.visible = el.visible;
|
||||
}
|
||||
|
||||
if (el.on && Object.keys(el.on).length > 0) {
|
||||
node.on = el.on;
|
||||
}
|
||||
|
||||
if (el.repeat) {
|
||||
node.repeat = el.repeat;
|
||||
}
|
||||
|
||||
if (el.children && el.children.length > 0) {
|
||||
node.children = el.children.map(resolve);
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (spec.state && Object.keys(spec.state).length > 0) {
|
||||
result.state = spec.state;
|
||||
}
|
||||
|
||||
result.elements = resolve(spec.root);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
@@ -96,15 +40,11 @@ export function Playground() {
|
||||
null,
|
||||
);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [streamLines, setStreamLines] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("json");
|
||||
const [catalogSection, setCatalogSection] = useState<
|
||||
"components" | "actions"
|
||||
>("components");
|
||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track the currently generating version ID
|
||||
@@ -116,8 +56,6 @@ export function Playground() {
|
||||
const {
|
||||
spec: apiSpec,
|
||||
isStreaming,
|
||||
usage: streamUsage,
|
||||
rawLines: streamRawLines,
|
||||
send,
|
||||
clear,
|
||||
} = useUIStream({
|
||||
@@ -155,11 +93,6 @@ export function Playground() {
|
||||
: (selectedVersion?.tree ??
|
||||
(isSelectedVersionGenerating ? apiSpec : null));
|
||||
|
||||
// Raw JSONL lines: live from stream during generation, or stored per version
|
||||
const currentRawLines = isSelectedVersionGenerating
|
||||
? streamRawLines
|
||||
: (selectedVersion?.rawLines ?? []);
|
||||
|
||||
// Keep the ref updated with the current tree for use in handleSubmit
|
||||
if (
|
||||
currentTree &&
|
||||
@@ -174,6 +107,24 @@ export function Playground() {
|
||||
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [versions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiSpec) {
|
||||
const streamLine = JSON.stringify({ tree: apiSpec });
|
||||
if (
|
||||
!streamLines.includes(streamLine) &&
|
||||
Object.keys(apiSpec.elements).length > 0
|
||||
) {
|
||||
setStreamLines((prev) => {
|
||||
const lastLine = prev[prev.length - 1];
|
||||
if (lastLine !== streamLine) {
|
||||
return [...prev, streamLine];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [apiSpec, streamLines]);
|
||||
|
||||
// Update version when streaming completes
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -186,19 +137,13 @@ export function Playground() {
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === completedVersionId
|
||||
? {
|
||||
...v,
|
||||
tree: apiSpec,
|
||||
status: "complete" as const,
|
||||
usage: streamUsage,
|
||||
rawLines: streamRawLines,
|
||||
}
|
||||
? { ...v, tree: apiSpec, status: "complete" as const }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
}, [isStreaming, apiSpec, streamUsage, streamRawLines]);
|
||||
}, [isStreaming, apiSpec]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!inputValue.trim() || isStreaming) return;
|
||||
@@ -209,14 +154,13 @@ export function Playground() {
|
||||
prompt: inputValue.trim(),
|
||||
tree: null,
|
||||
status: "generating",
|
||||
usage: null,
|
||||
rawLines: [],
|
||||
};
|
||||
|
||||
generatingVersionIdRef.current = newVersionId;
|
||||
setVersions((prev) => [...prev, newVersion]);
|
||||
setSelectedVersionId(newVersionId);
|
||||
setInputValue("");
|
||||
setStreamLines([]); // Reset stream lines for new generation
|
||||
|
||||
// Pass the current tree as context so the API can iterate on it
|
||||
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
||||
@@ -236,11 +180,6 @@ export function Playground() {
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
|
||||
const nestedCode = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) return "// waiting...";
|
||||
return JSON.stringify(specToNested(currentTree), null, 2);
|
||||
}, [currentTree]);
|
||||
|
||||
const generatedCode = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) {
|
||||
return "// Generate a UI to see the code";
|
||||
@@ -323,23 +262,17 @@ ${jsx}
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushSync(() => setInputValue(prompt));
|
||||
// chatPane is rendered in both desktop and mobile layouts,
|
||||
// so inputRef may point to the hidden instance. Find the
|
||||
// textarea in the same layout container as the clicked button.
|
||||
const container = (e.currentTarget as HTMLElement).closest(
|
||||
".h-full.flex.flex-col",
|
||||
);
|
||||
const el =
|
||||
container?.querySelector<HTMLTextAreaElement>(
|
||||
"textarea",
|
||||
) ?? inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(prompt.length, prompt.length);
|
||||
}
|
||||
onClick={() => {
|
||||
setInputValue(prompt);
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(
|
||||
prompt.length,
|
||||
prompt.length,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
@@ -373,13 +306,6 @@ ${jsx}
|
||||
<span className="text-xs text-red-500 shrink-0">failed</span>
|
||||
)}
|
||||
</div>
|
||||
{version.usage && (
|
||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||
{version.usage.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -404,7 +330,6 @@ ${jsx}
|
||||
placeholder="Describe changes..."
|
||||
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{versions.length > 0 ? (
|
||||
@@ -412,6 +337,7 @@ ${jsx}
|
||||
onClick={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
setStreamLines([]);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -464,104 +390,11 @@ ${jsx}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Catalog data for the catalog tab
|
||||
const catalogData = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const raw = playgroundCatalog.data as any;
|
||||
|
||||
function extractFields(zodObj: unknown): { name: string; type: string }[] {
|
||||
if (!zodObj) return [];
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const obj = zodObj as any;
|
||||
// Zod v4: shape is a plain object; Zod v3: shape is via _def.shape()
|
||||
const shape =
|
||||
typeof obj.shape === "object"
|
||||
? obj.shape
|
||||
: typeof obj._def?.shape === "function"
|
||||
? obj._def.shape()
|
||||
: typeof obj._def?.shape === "object"
|
||||
? obj._def.shape
|
||||
: null;
|
||||
if (!shape) return [];
|
||||
|
||||
return Object.entries(shape).map(([name, schema]) => {
|
||||
let type = "unknown";
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const s = schema as any;
|
||||
const typeName: string =
|
||||
s?._zod?.def?.type ?? s?._def?.typeName ?? "";
|
||||
if (typeName.includes("string")) type = "string";
|
||||
else if (typeName.includes("number")) type = "number";
|
||||
else if (typeName.includes("boolean")) type = "boolean";
|
||||
else if (typeName.includes("array")) type = "array";
|
||||
else if (typeName.includes("enum")) {
|
||||
const values = s?._zod?.def?.values ?? s?._def?.values;
|
||||
type = Array.isArray(values) ? values.join(" | ") : "enum";
|
||||
} else if (typeName.includes("union")) type = "union";
|
||||
else if (typeName.includes("nullable")) {
|
||||
const inner = s?._zod?.def?.innerType ?? s?._def?.innerType;
|
||||
const innerName: string =
|
||||
inner?._zod?.def?.type ?? inner?._def?.typeName ?? "";
|
||||
if (innerName.includes("string")) type = "string?";
|
||||
else if (innerName.includes("number")) type = "number?";
|
||||
else if (innerName.includes("boolean")) type = "boolean?";
|
||||
else if (innerName.includes("array")) type = "array?";
|
||||
else if (innerName.includes("enum")) {
|
||||
const values = inner?._zod?.def?.values ?? inner?._def?.values;
|
||||
type = Array.isArray(values)
|
||||
? `(${values.join(" | ")})?`
|
||||
: "enum?";
|
||||
} else type = "optional";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { name, type };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const components = Object.entries(raw.components ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
props: extractFields(def.props),
|
||||
slots: (def.slots as string[]) ?? [],
|
||||
events: (def.events as string[]) ?? [],
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const actions = Object.entries(raw.actions ?? {})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map(([name, def]: [string, any]) => ({
|
||||
name,
|
||||
description: (def.description as string) ?? "",
|
||||
params: extractFields(def.params),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { components, actions };
|
||||
}, []);
|
||||
|
||||
// Code pane content
|
||||
const copyText =
|
||||
activeTab === "stream"
|
||||
? currentRawLines.join("\n")
|
||||
: activeTab === "json"
|
||||
? jsonCode
|
||||
: activeTab === "nested"
|
||||
? nestedCode
|
||||
: "";
|
||||
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
{(["json", "stream"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -575,132 +408,16 @@ ${jsx}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{activeTab !== "catalog" && (
|
||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||
)}
|
||||
<CopyButton
|
||||
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "components",
|
||||
label: `components (${catalogData.components.length})`,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: `actions (${catalogData.actions.length})`,
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setCatalogSection(key)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
catalogSection === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{catalogSection === "components" ? (
|
||||
<div className="space-y-3">
|
||||
{catalogData.components.map((comp) => (
|
||||
<div
|
||||
key={comp.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{comp.name}
|
||||
</span>
|
||||
{comp.slots.length > 0 && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
slots: {comp.slots.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{comp.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{comp.description}
|
||||
</p>
|
||||
)}
|
||||
{comp.props.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{comp.props.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{comp.events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{comp.events.map((e) => (
|
||||
<span
|
||||
key={e}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
on.{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{catalogData.actions.map((action) => (
|
||||
<div
|
||||
key={action.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{action.name}
|
||||
</span>
|
||||
{action.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
{action.params.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{action.params.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === "stream" ? (
|
||||
currentRawLines.length > 0 ? (
|
||||
{activeTab === "stream" ? (
|
||||
streamLines.length > 0 ? (
|
||||
<CodeBlock
|
||||
code={currentRawLines.join("\n")}
|
||||
code={streamLines.join("\n")}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
@@ -710,8 +427,6 @@ ${jsx}
|
||||
{isStreaming ? "streaming..." : "// waiting for generation"}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === "nested" ? (
|
||||
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
||||
) : (
|
||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||
)}
|
||||
@@ -750,11 +465,7 @@ ${jsx}
|
||||
{renderView === "preview" ? (
|
||||
currentTree && currentTree.root ? (
|
||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||
<PlaygroundRenderer
|
||||
spec={currentTree}
|
||||
data={currentTree.state}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
<PlaygroundRenderer spec={currentTree} loading={isStreaming} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
||||
@@ -796,377 +507,68 @@ ${jsx}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: toolbar + content + prompt input */}
|
||||
{/* Mobile: Single pane with bottom tabs */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
{/* Top toolbar */}
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3 shrink-0 overflow-x-auto">
|
||||
{/* Version badge */}
|
||||
<button
|
||||
onClick={() => setVersionsSheetOpen(true)}
|
||||
className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
|
||||
{/* Panes - all in DOM, visibility controlled */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
|
||||
>
|
||||
v
|
||||
{versions.length > 0
|
||||
? versions.findIndex((v) => v.id === selectedVersionId) + 1 ||
|
||||
versions.length
|
||||
: 0}
|
||||
</button>
|
||||
{/* Code tabs */}
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{/* Preview / code toggle */}
|
||||
{[
|
||||
{ key: "preview" as const, label: "preview" },
|
||||
{ key: "generated-code" as const, label: "code" },
|
||||
].map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMobileView(key)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
{mobileView === "catalog" ? (
|
||||
<div className="h-full flex flex-col text-sm">
|
||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "components",
|
||||
label: `components (${catalogData.components.length})`,
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: `actions (${catalogData.actions.length})`,
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setCatalogSection(key)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
catalogSection === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{catalogSection === "components" ? (
|
||||
<div className="space-y-3">
|
||||
{catalogData.components.map((comp) => (
|
||||
<div
|
||||
key={comp.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{comp.name}
|
||||
</span>
|
||||
{comp.slots.length > 0 && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
|
||||
slots: {comp.slots.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{comp.description && (
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{comp.description}
|
||||
</p>
|
||||
)}
|
||||
{comp.props.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-1">
|
||||
{comp.props.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{comp.events.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{comp.events.map((e) => (
|
||||
<span
|
||||
key={e}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
on.{e}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{catalogData.actions.map((action) => (
|
||||
<div
|
||||
key={action.name}
|
||||
className="pb-3 border-b border-border last:border-b-0"
|
||||
>
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{action.name}
|
||||
</span>
|
||||
{action.description && (
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
{action.description}
|
||||
</p>
|
||||
)}
|
||||
{action.params.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{action.params.map((p) => (
|
||||
<span
|
||||
key={p.name}
|
||||
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{p.name}
|
||||
<span className="text-green-700/50 dark:text-green-400/50">
|
||||
: {p.type}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : mobileView === "stream" ? (
|
||||
currentRawLines.length > 0 ? (
|
||||
<CodeBlock
|
||||
code={currentRawLines.join("\n")}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{isStreaming ? "streaming..." : "// waiting for generation"}
|
||||
</div>
|
||||
)
|
||||
) : mobileView === "nested" ? (
|
||||
<CodeBlock
|
||||
code={nestedCode}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
) : mobileView === "json" ? (
|
||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||
) : mobileView === "preview" ? (
|
||||
currentTree && currentTree.root ? (
|
||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||
<PlaygroundRenderer
|
||||
spec={currentTree}
|
||||
data={currentTree.state}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center px-4">
|
||||
{isStreaming ? (
|
||||
<p className="text-sm text-muted-foreground/50">
|
||||
generating...
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Describe what you want to build, then iterate on it.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
flushSync(() => setInputValue(prompt));
|
||||
mobileInputRef.current?.focus();
|
||||
mobileInputRef.current?.setSelectionRange(
|
||||
prompt.length,
|
||||
prompt.length,
|
||||
);
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* generated-code */
|
||||
<CodeBlock
|
||||
code={generatedCode}
|
||||
lang="tsx"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt input pinned to bottom */}
|
||||
<div
|
||||
className="border-t border-border p-3 shrink-0 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
mobileInputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={mobileInputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe changes..."
|
||||
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{versions.length > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={() => clear()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{chatPane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
|
||||
>
|
||||
{codePane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
|
||||
>
|
||||
{previewPane}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Versions sheet */}
|
||||
<Sheet open={versionsSheetOpen} onOpenChange={setVersionsSheetOpen}>
|
||||
<SheetContent>
|
||||
<SheetTitle className="text-sm font-mono mb-4">Versions</SheetTitle>
|
||||
<div className="flex-1 overflow-y-auto space-y-1">
|
||||
{versions.map((version, index) => (
|
||||
<button
|
||||
key={version.id}
|
||||
onClick={() => {
|
||||
setSelectedVersionId(version.id);
|
||||
setVersionsSheetOpen(false);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
|
||||
selectedVersionId === version.id
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
|
||||
v{index + 1}
|
||||
</span>
|
||||
<span className="truncate flex-1">{version.prompt}</span>
|
||||
{version.status === "generating" && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
{version.status === "error" && (
|
||||
<span className="text-xs text-red-500 shrink-0">
|
||||
failed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{version.usage && (
|
||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||
{version.usage.totalTokens.toLocaleString()} tokens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{versions.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground px-3">
|
||||
No versions yet. Enter a prompt to get started.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
{/* Bottom tab bar */}
|
||||
<div className="border-t border-border flex shrink-0">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "chat",
|
||||
label: "Chat",
|
||||
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "Preview",
|
||||
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMobilePane(key)}
|
||||
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
|
||||
mobilePane === key ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||
</svg>
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"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 };
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -22,11 +22,9 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
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",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
@@ -48,7 +46,7 @@ function Button({
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -1,33 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -1,257 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants, type Button } from "@/components/ui/button";
|
||||
|
||||
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
|
||||
return <li data-slot="pagination-item" {...props} />;
|
||||
}
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<React.ComponentProps<typeof Button>, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
function PaginationLink({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) {
|
||||
return (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationPrevious({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationNext({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) {
|
||||
return (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</PaginationLink>
|
||||
);
|
||||
}
|
||||
|
||||
function PaginationEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-accent animate-pulse rounded-md", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Slider as SliderPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Slider({
|
||||
className,
|
||||
defaultValue,
|
||||
value,
|
||||
min = 0,
|
||||
max = 100,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
|
||||
const _values = React.useMemo(
|
||||
() =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
: Array.isArray(defaultValue)
|
||||
? defaultValue
|
||||
: [min, max],
|
||||
[value, defaultValue, min, max],
|
||||
);
|
||||
|
||||
return (
|
||||
<SliderPrimitive.Root
|
||||
data-slot="slider"
|
||||
defaultValue={defaultValue}
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className={cn(
|
||||
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className={cn(
|
||||
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
|
||||
)}
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
{Array.from({ length: _values.length }, (_, index) => (
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Slider };
|
||||
@@ -1,29 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme={resolvedTheme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
@@ -1,47 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,266 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web playground component catalog
|
||||
*
|
||||
* This defines the components available for AI generation in the playground.
|
||||
* Components map to implementations in lib/catalog/components.tsx
|
||||
* Actions map to handlers in lib/catalog/actions.ts
|
||||
*/
|
||||
export const playgroundCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Layout Components
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
|
||||
centered: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
},
|
||||
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
|
||||
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex container for layouts",
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z
|
||||
.union([
|
||||
z.literal(1),
|
||||
z.literal(2),
|
||||
z.literal(3),
|
||||
z.literal(4),
|
||||
z.literal(5),
|
||||
z.literal(6),
|
||||
])
|
||||
.nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout (1-6 columns)",
|
||||
},
|
||||
|
||||
Divider: {
|
||||
props: z.object({}),
|
||||
description: "Horizontal separator line",
|
||||
},
|
||||
|
||||
// Form Inputs
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
}),
|
||||
description: "Multi-line text input",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
}),
|
||||
description: "Dropdown select input",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Checkbox input",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
}),
|
||||
description: "Radio button group",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Toggle switch input",
|
||||
},
|
||||
|
||||
// Actions
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
|
||||
action: z.string().nullable(),
|
||||
actionParams: z.record(z.string(), z.unknown()).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. Use action to specify the action name and actionParams for parameters.",
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
}),
|
||||
description: "Anchor link",
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "caption", "muted"]).nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
},
|
||||
|
||||
// Data Display
|
||||
Image: {
|
||||
props: z.object({
|
||||
alt: z.string(),
|
||||
width: z.number().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string().nullable(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).nullable(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
},
|
||||
|
||||
Rating: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarGraph: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Vertical bar chart",
|
||||
},
|
||||
|
||||
LineGraph: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Line chart with points",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
// Demo actions for the playground
|
||||
buttonClick: {
|
||||
params: z.object({
|
||||
message: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Triggered when a button is clicked. Shows a toast with the message.",
|
||||
},
|
||||
|
||||
formSubmit: {
|
||||
params: z.object({
|
||||
formName: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Triggered when a form is submitted. Shows a toast confirming submission.",
|
||||
},
|
||||
|
||||
linkClick: {
|
||||
params: z.object({
|
||||
href: z.string(),
|
||||
}),
|
||||
description:
|
||||
"Triggered when a link is clicked. Shows a toast with the destination.",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
type ActionHandler = (
|
||||
params: Record<string, unknown> | undefined,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Demo action handlers for the playground
|
||||
*
|
||||
* These show toast notifications to demonstrate actions work.
|
||||
* In a real app, these would call APIs or perform state updates.
|
||||
*/
|
||||
export const actionHandlers: Record<string, ActionHandler> = {
|
||||
buttonClick: async (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
|
||||
formSubmit: async (params) => {
|
||||
const formName = (params?.formName as string) || "Form";
|
||||
toast.success(`${formName} submitted successfully!`);
|
||||
},
|
||||
|
||||
linkClick: async (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute an action by name with the given parameters
|
||||
*/
|
||||
export async function executeAction(
|
||||
actionName: string,
|
||||
params?: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const handler = actionHandlers[actionName];
|
||||
|
||||
if (handler) {
|
||||
await handler(params);
|
||||
} else {
|
||||
// Fallback for unknown actions - just show a toast
|
||||
toast.info(`Action: ${actionName}`, {
|
||||
description: params ? JSON.stringify(params) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,588 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { z } from "zod";
|
||||
|
||||
// shadcn components
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { playgroundCatalog } from "../catalog";
|
||||
|
||||
// =============================================================================
|
||||
// Types - Inferred from Catalog
|
||||
// =============================================================================
|
||||
|
||||
type CatalogComponents = typeof playgroundCatalog.data.components;
|
||||
|
||||
export type InferProps<K extends keyof CatalogComponents> =
|
||||
CatalogComponents[K] extends { props: z.ZodType<infer P> } ? P : never;
|
||||
|
||||
export interface ComponentContext<K extends keyof CatalogComponents> {
|
||||
props: InferProps<K>;
|
||||
children?: ReactNode;
|
||||
onAction?: (action: {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
}) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export type ComponentFn<K extends keyof CatalogComponents> = (
|
||||
ctx: ComponentContext<K>,
|
||||
) => ReactNode;
|
||||
|
||||
// =============================================================================
|
||||
// Components - Type-safe with Catalog using shadcn/ui
|
||||
// =============================================================================
|
||||
|
||||
export const components: { [K in keyof CatalogComponents]: ComponentFn<K> } = {
|
||||
// Layout Components
|
||||
Card: ({ props, children }) => {
|
||||
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-4 bg-card text-card-foreground overflow-hidden ${maxWidthClass} ${centeredClass}`}
|
||||
>
|
||||
{props.title && (
|
||||
<div className="font-semibold text-sm mb-1 text-left">
|
||||
{props.title}
|
||||
</div>
|
||||
)}
|
||||
{props.description && (
|
||||
<div className="text-xs text-muted-foreground mb-3 text-left">
|
||||
{props.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Stack: ({ props, children }) => {
|
||||
const isHorizontal = props.direction === "horizontal";
|
||||
|
||||
const gapClass =
|
||||
props.gap === "lg"
|
||||
? "gap-4"
|
||||
: props.gap === "md"
|
||||
? "gap-3"
|
||||
: props.gap === "sm"
|
||||
? "gap-2"
|
||||
: props.gap === "none"
|
||||
? "gap-0"
|
||||
: "gap-3";
|
||||
|
||||
const alignClass =
|
||||
props.align === "center"
|
||||
? "items-center"
|
||||
: props.align === "end"
|
||||
? "items-end"
|
||||
: props.align === "stretch"
|
||||
? "items-stretch"
|
||||
: "items-start";
|
||||
|
||||
const justifyClass =
|
||||
props.justify === "center"
|
||||
? "justify-center"
|
||||
: props.justify === "end"
|
||||
? "justify-end"
|
||||
: props.justify === "between"
|
||||
? "justify-between"
|
||||
: props.justify === "around"
|
||||
? "justify-around"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Grid: ({ props, children }) => {
|
||||
const cols =
|
||||
props.columns === 6
|
||||
? "grid-cols-6"
|
||||
: props.columns === 5
|
||||
? "grid-cols-5"
|
||||
: 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-4" : props.gap === "sm" ? "gap-2" : "gap-3";
|
||||
|
||||
return <div className={`grid ${cols} ${gridGap}`}>{children}</div>;
|
||||
},
|
||||
|
||||
Divider: () => <Separator className="my-3" />,
|
||||
|
||||
// Form Inputs
|
||||
Input: ({ props }) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={props.name}>{props.label}</Label>
|
||||
<Input
|
||||
id={props.name}
|
||||
name={props.name}
|
||||
type={props.type ?? "text"}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
Textarea: ({ props }) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={props.name}>{props.label}</Label>
|
||||
<Textarea
|
||||
id={props.name}
|
||||
name={props.name}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
Select: ({ props }) => {
|
||||
const [value, setValue] = useState<string>("");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
<Select value={value} onValueChange={setValue}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={props.placeholder ?? "Select..."} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{props.options.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Checkbox: ({ props }) => {
|
||||
const [checked, setChecked] = useState(!!props.checked);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={props.name}
|
||||
checked={checked}
|
||||
onCheckedChange={(c) => setChecked(c === true)}
|
||||
/>
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Radio: ({ props }) => {
|
||||
const [value, setValue] = useState(props.options[0] ?? "");
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && <Label>{props.label}</Label>}
|
||||
<RadioGroup value={value} onValueChange={setValue}>
|
||||
{props.options.map((opt) => (
|
||||
<div key={opt} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt} id={`${props.name}-${opt}`} />
|
||||
<Label
|
||||
htmlFor={`${props.name}-${opt}`}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{opt}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Switch: ({ props }) => {
|
||||
const [checked, setChecked] = useState(!!props.checked);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between space-x-2">
|
||||
<Label htmlFor={props.name} className="cursor-pointer">
|
||||
{props.label}
|
||||
</Label>
|
||||
<Switch
|
||||
id={props.name}
|
||||
checked={checked}
|
||||
onCheckedChange={setChecked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
// Actions
|
||||
Button: ({ props, onAction, loading }) => {
|
||||
const variant =
|
||||
props.variant === "danger"
|
||||
? "destructive"
|
||||
: props.variant === "secondary"
|
||||
? "secondary"
|
||||
: "default";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
onAction?.({
|
||||
name: props.action ?? "buttonClick",
|
||||
params: props.actionParams ?? { message: props.label },
|
||||
})
|
||||
}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
|
||||
Link: ({ props, onAction }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0"
|
||||
onClick={() =>
|
||||
onAction?.({
|
||||
name: "linkClick",
|
||||
params: { href: props.href },
|
||||
})
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// Typography
|
||||
Heading: ({ props }) => {
|
||||
const level = props.level ?? "h2";
|
||||
const headingClass =
|
||||
level === "h1"
|
||||
? "text-2xl font-bold"
|
||||
: level === "h3"
|
||||
? "text-base font-semibold"
|
||||
: level === "h4"
|
||||
? "text-sm font-semibold"
|
||||
: "text-lg font-semibold";
|
||||
|
||||
if (level === "h1")
|
||||
return <h1 className={`${headingClass} text-left`}>{props.text}</h1>;
|
||||
if (level === "h3")
|
||||
return <h3 className={`${headingClass} text-left`}>{props.text}</h3>;
|
||||
if (level === "h4")
|
||||
return <h4 className={`${headingClass} text-left`}>{props.text}</h4>;
|
||||
return <h2 className={`${headingClass} text-left`}>{props.text}</h2>;
|
||||
},
|
||||
|
||||
Text: ({ props }) => {
|
||||
const textClass =
|
||||
props.variant === "caption"
|
||||
? "text-xs"
|
||||
: props.variant === "muted"
|
||||
? "text-sm text-muted-foreground"
|
||||
: "text-sm";
|
||||
|
||||
return <p className={`${textClass} text-left`}>{props.text}</p>;
|
||||
},
|
||||
|
||||
// Data Display
|
||||
Image: ({ props }) => {
|
||||
const imgStyle = {
|
||||
width: props.width ?? 80,
|
||||
height: props.height ?? 60,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-muted border border-border rounded flex items-center justify-center text-xs text-muted-foreground aspect-video"
|
||||
style={imgStyle}
|
||||
>
|
||||
{props.alt || "img"}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Avatar: ({ props }) => {
|
||||
const name = props.name || "?";
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
const avatarSize =
|
||||
props.size === "lg"
|
||||
? "w-12 h-12 text-base"
|
||||
: props.size === "sm"
|
||||
? "w-8 h-8 text-xs"
|
||||
: "w-10 h-10 text-sm";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Badge: ({ props }) => {
|
||||
const variant =
|
||||
props.variant === "success" || props.variant === "warning"
|
||||
? "secondary"
|
||||
: props.variant === "danger"
|
||||
? "destructive"
|
||||
: "default";
|
||||
|
||||
// Add custom colors for success/warning
|
||||
const customClass =
|
||||
props.variant === "success"
|
||||
? "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100"
|
||||
: props.variant === "warning"
|
||||
? "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Badge variant={variant} className={customClass}>
|
||||
{props.text}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
|
||||
Alert: ({ props }) => {
|
||||
const variant = props.type === "error" ? "destructive" : "default";
|
||||
|
||||
// Custom colors for different alert types
|
||||
const customClass =
|
||||
props.type === "success"
|
||||
? "border-green-200 bg-green-50 text-green-900 dark:border-green-800 dark:bg-green-950 dark:text-green-100"
|
||||
: props.type === "warning"
|
||||
? "border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-800 dark:bg-yellow-950 dark:text-yellow-100"
|
||||
: props.type === "info"
|
||||
? "border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-100"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Alert variant={variant} className={customClass}>
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{props.message && <AlertDescription>{props.message}</AlertDescription>}
|
||||
</Alert>
|
||||
);
|
||||
},
|
||||
|
||||
Progress: ({ props }) => {
|
||||
const value = Math.min(100, Math.max(0, props.value || 0));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm text-muted-foreground">{props.label}</Label>
|
||||
)}
|
||||
<Progress value={value} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
Rating: ({ props }) => {
|
||||
const ratingValue = props.value || 0;
|
||||
const maxRating = props.max ?? 5;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.label && (
|
||||
<Label className="text-sm text-muted-foreground">{props.label}</Label>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{Array.from({ length: maxRating }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`text-lg ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
|
||||
>
|
||||
*
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarGraph: ({ props }) => {
|
||||
const data = props.data || [];
|
||||
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
{data.map((d, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="text-xs text-muted-foreground">{d.value}</div>
|
||||
<div className="w-full h-24 flex items-end">
|
||||
<div
|
||||
className="w-full bg-primary rounded-t transition-all"
|
||||
style={{
|
||||
height: `${(d.value / maxValue) * 100}%`,
|
||||
minHeight: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate w-full text-center">
|
||||
{d.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
LineGraph: ({ props }) => {
|
||||
const data = props.data || [];
|
||||
const maxValue = Math.max(...data.map((d) => d.value));
|
||||
const minValue = Math.min(...data.map((d) => d.value));
|
||||
const range = maxValue - minValue || 1;
|
||||
|
||||
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;
|
||||
|
||||
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="space-y-2">
|
||||
{props.title && (
|
||||
<div className="text-sm font-medium text-left">{props.title}</div>
|
||||
)}
|
||||
<div className="relative h-28">
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
|
||||
<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"
|
||||
/>
|
||||
{pathD && (
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-primary"
|
||||
/>
|
||||
)}
|
||||
{points.map((p, i) => (
|
||||
<circle
|
||||
key={i}
|
||||
cx={p.x}
|
||||
cy={p.y}
|
||||
r="4"
|
||||
className="fill-primary"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
{data.length > 0 && (
|
||||
<div className="flex justify-between">
|
||||
{data.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-xs text-muted-foreground text-center"
|
||||
style={{ width: `${100 / data.length}%` }}
|
||||
>
|
||||
{d.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Fallback component for unknown types
|
||||
export function Fallback({ type }: { type: string }) {
|
||||
return <div className="text-xs text-muted-foreground">[{type}]</div>;
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* Converts raw MDX content to clean Markdown suitable for AI agents.
|
||||
*
|
||||
* Transformations:
|
||||
* - Remove `export` statements (metadata, etc.)
|
||||
* - Remove `import` statements
|
||||
* - Replace `<PackageInstall packages="x y" />` with a fenced bash code block
|
||||
* - Strip standalone JSX callout divs (the amber concept boxes)
|
||||
* - Pass everything else through as-is (already valid Markdown)
|
||||
*/
|
||||
export function mdxToCleanMarkdown(raw: string): string {
|
||||
const lines = raw.split("\n");
|
||||
const out: string[] = [];
|
||||
let inJsxBlock = false;
|
||||
let jsxDepth = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Skip export and import statements
|
||||
if (trimmed.startsWith("export ") || trimmed.startsWith("import ")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle PackageInstall component
|
||||
const pkgMatch = trimmed.match(
|
||||
/<PackageInstall\s+packages="([^"]+)"\s*\/>/,
|
||||
);
|
||||
if (pkgMatch) {
|
||||
const packages = pkgMatch[1];
|
||||
out.push("```bash");
|
||||
out.push(`pnpm add ${packages}`);
|
||||
out.push("```");
|
||||
out.push("");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track JSX blocks (like the callout divs) and skip them
|
||||
if (
|
||||
!inJsxBlock &&
|
||||
trimmed.startsWith("<div ") &&
|
||||
trimmed.includes("className=")
|
||||
) {
|
||||
inJsxBlock = true;
|
||||
jsxDepth = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inJsxBlock) {
|
||||
// Count opening/closing div tags to handle nesting
|
||||
const opens = (line.match(/<div[\s>]/g) || []).length;
|
||||
const closes = (line.match(/<\/div>/g) || []).length;
|
||||
jsxDepth += opens - closes;
|
||||
if (jsxDepth <= 0) {
|
||||
inJsxBlock = false;
|
||||
jsxDepth = 0;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
// Clean up leading blank lines
|
||||
let result = out.join("\n");
|
||||
result = result.replace(/^\n+/, "\n").trim();
|
||||
return result;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Single source of truth for page titles.
|
||||
* Used by both page metadata exports and the OG image route.
|
||||
*
|
||||
* Keys mirror the page's URL path (e.g., "docs/changelog" → /og/docs/changelog).
|
||||
* Values are display titles (without the "| json-render" suffix -- the layout template adds that).
|
||||
*/
|
||||
export const PAGE_TITLES: Record<string, string> = {
|
||||
// Home (no slug)
|
||||
"": "AI-generated UI with guardrails",
|
||||
|
||||
// Top-level
|
||||
playground: "Playground",
|
||||
|
||||
// Docs
|
||||
docs: "Introduction",
|
||||
"docs/quick-start": "Quick Start",
|
||||
"docs/installation": "Installation",
|
||||
"docs/catalog": "Catalog",
|
||||
"docs/schemas": "Schemas",
|
||||
"docs/specs": "Specs",
|
||||
"docs/registry": "Registry",
|
||||
"docs/streaming": "Streaming",
|
||||
"docs/validation": "Validation",
|
||||
"docs/data-binding": "Data Binding",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
"docs/ai-sdk": "AI SDK Integration",
|
||||
"docs/adaptive-cards": "Adaptive Cards Integration",
|
||||
"docs/openapi": "OpenAPI Integration",
|
||||
"docs/a2ui": "A2UI Integration",
|
||||
"docs/ag-ui": "AG-UI Integration",
|
||||
"docs/changelog": "Changelog",
|
||||
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core API",
|
||||
"docs/api/react": "@json-render/react API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
"docs/api/remotion": "@json-render/remotion API",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the page title for a given slug.
|
||||
* Returns null if the slug is not in the whitelist.
|
||||
*/
|
||||
export function getPageTitle(slug: string): string | null {
|
||||
return slug in PAGE_TITLES ? PAGE_TITLES[slug]! : null;
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web playground component catalog
|
||||
*
|
||||
* This defines the components available for AI generation in the playground.
|
||||
* Components and actions are implemented in lib/registry.tsx via defineRegistry.
|
||||
*
|
||||
* Keep schemas simple — one format per prop, no unions.
|
||||
* Fewer components = less confusion for the AI.
|
||||
*/
|
||||
export const playgroundCatalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// ── Layout ──────────────────────────────────────────────────────────
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
|
||||
centered: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
},
|
||||
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
|
||||
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex container for layouts",
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.number().nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout (1-6 columns)",
|
||||
},
|
||||
|
||||
Separator: {
|
||||
props: z.object({
|
||||
orientation: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
}),
|
||||
description: "Visual separator line",
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Tab navigation. Use statePath to bind the active tab value.",
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
|
||||
},
|
||||
|
||||
Collapsible: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
defaultOpen: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Collapsible section with trigger. Children render inside.",
|
||||
},
|
||||
|
||||
Dialog: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
openPath: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Modal dialog. Set openPath to a boolean state path. Use setState to toggle.",
|
||||
},
|
||||
|
||||
Drawer: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
openPath: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Bottom sheet drawer. Set openPath to a boolean state path. Use setState to toggle.",
|
||||
},
|
||||
|
||||
Carousel: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Horizontally scrollable carousel of cards.",
|
||||
},
|
||||
|
||||
// ── Data Display ────────────────────────────────────────────────────
|
||||
Table: {
|
||||
props: z.object({
|
||||
columns: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
|
||||
},
|
||||
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["body", "caption", "muted", "lead", "code"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
},
|
||||
|
||||
Image: {
|
||||
props: z.object({
|
||||
alt: z.string(),
|
||||
width: z.number().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string().nullable(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).nullable(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
props: z.object({
|
||||
width: z.string().nullable(),
|
||||
height: z.string().nullable(),
|
||||
rounded: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Loading placeholder skeleton",
|
||||
},
|
||||
|
||||
Spinner: {
|
||||
props: z.object({
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Loading spinner indicator",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
description: "Hover tooltip. Shows content on hover over text.",
|
||||
},
|
||||
|
||||
Popover: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
description: "Popover that appears on click of trigger.",
|
||||
},
|
||||
|
||||
Rating: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
},
|
||||
|
||||
// ── Charts ──────────────────────────────────────────────────────────
|
||||
BarGraph: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Vertical bar chart",
|
||||
},
|
||||
|
||||
LineGraph: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Line chart with points",
|
||||
},
|
||||
|
||||
// ── Form Inputs ─────────────────────────────────────────────────────
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["submit", "focus", "blur"],
|
||||
description: "Text input field. Use statePath for two-way binding.",
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
description: "Multi-line text input. Use statePath for binding.",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Dropdown select input. Use statePath for binding.",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Checkbox input. Use statePath for binding.",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Radio button group. Use statePath for binding.",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle switch. Use statePath for binding.",
|
||||
},
|
||||
|
||||
Slider: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
step: z.number().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Range slider input. Use statePath for binding.",
|
||||
},
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────────
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Anchor link. Bind on.press for click handler.",
|
||||
},
|
||||
|
||||
DropdownMenu: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
events: ["select"],
|
||||
description: "Dropdown menu with trigger button and selectable items.",
|
||||
},
|
||||
|
||||
Toggle: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
pressed: z.boolean().nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
variant: z.enum(["default", "outline"]).nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Toggle button. Use statePath for pressed state binding.",
|
||||
},
|
||||
|
||||
ToggleGroup: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'.",
|
||||
},
|
||||
|
||||
ButtonGroup: {
|
||||
props: z.object({
|
||||
buttons: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
statePath: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Segmented button group. Use statePath for selected value.",
|
||||
},
|
||||
|
||||
Pagination: {
|
||||
props: z.object({
|
||||
totalPages: z.number(),
|
||||
statePath: z.string(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Page navigation. Bind statePath to a number for current page.",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
value: z.unknown(),
|
||||
}),
|
||||
description: "Update a value in the state model at the given path.",
|
||||
},
|
||||
|
||||
pushState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
value: z.unknown(),
|
||||
clearPath: z.string().optional(),
|
||||
}),
|
||||
description:
|
||||
'Append an item to an array in state. Value can contain {path:"/statePath"} refs and "$id" for auto IDs. clearPath resets another path after pushing.',
|
||||
},
|
||||
|
||||
removeState: {
|
||||
params: z.object({
|
||||
path: z.string(),
|
||||
index: z.number(),
|
||||
}),
|
||||
description: "Remove an item from an array in state at the given index.",
|
||||
},
|
||||
|
||||
buttonClick: {
|
||||
params: z.object({
|
||||
message: z.string().nullable(),
|
||||
}),
|
||||
description: "Shows a toast with the message.",
|
||||
},
|
||||
|
||||
formSubmit: {
|
||||
params: z.object({
|
||||
formName: z.string().nullable(),
|
||||
}),
|
||||
description: "Shows a toast confirming form submission.",
|
||||
},
|
||||
|
||||
linkClick: {
|
||||
params: z.object({
|
||||
href: z.string(),
|
||||
}),
|
||||
description: "Shows a toast with the link destination.",
|
||||
},
|
||||
},
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Renderer,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { registry, Fallback } from "./registry";
|
||||
|
||||
// =============================================================================
|
||||
// PlaygroundRenderer
|
||||
// =============================================================================
|
||||
|
||||
interface PlaygroundRendererProps {
|
||||
spec: Spec | null;
|
||||
data?: Record<string, unknown>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const fallbackRenderer = (renderProps: { element: { type: string } }) => (
|
||||
<Fallback type={renderProps.element.type} />
|
||||
);
|
||||
|
||||
/**
|
||||
* Action handlers for the playground preview.
|
||||
* These are passed to ActionProvider so custom actions (buttonClick, formSubmit,
|
||||
* linkClick) work when triggered from the rendered UI.
|
||||
*/
|
||||
const actionHandlers: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => void
|
||||
> = {
|
||||
buttonClick: (params) => {
|
||||
const message = (params?.message as string) || "Button clicked!";
|
||||
toast.success(message);
|
||||
},
|
||||
formSubmit: (params) => {
|
||||
const formName = (params?.formName as string) || "Form";
|
||||
toast.success(`${formName} submitted successfully!`);
|
||||
},
|
||||
linkClick: (params) => {
|
||||
const href = (params?.href as string) || "#";
|
||||
toast.info(`Navigating to: ${href}`);
|
||||
},
|
||||
};
|
||||
|
||||
export function PlaygroundRenderer({
|
||||
spec,
|
||||
data,
|
||||
loading,
|
||||
}: PlaygroundRendererProps): ReactNode {
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data ?? spec.state}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRenderer}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import {
|
||||
Renderer,
|
||||
type ComponentRegistry,
|
||||
type Spec,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
|
||||
import { components, Fallback } from "./catalog/components";
|
||||
import { executeAction } from "./catalog/actions";
|
||||
|
||||
// =============================================================================
|
||||
// PlaygroundRenderer
|
||||
// =============================================================================
|
||||
|
||||
interface PlaygroundRendererProps {
|
||||
spec: Spec | null;
|
||||
data?: Record<string, unknown>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
// Build registry once - stable reference to prevent input focus loss
|
||||
function buildRegistry(loading?: boolean): ComponentRegistry {
|
||||
const registry: ComponentRegistry = {};
|
||||
|
||||
for (const [name, Component] of Object.entries(components)) {
|
||||
registry[name] = (renderProps: {
|
||||
element: { props: Record<string, unknown>; type: string };
|
||||
children?: ReactNode;
|
||||
}) => (
|
||||
<Component
|
||||
props={renderProps.element.props as never}
|
||||
onAction={(a) => executeAction(a.name, a.params)}
|
||||
loading={loading}
|
||||
>
|
||||
{renderProps.children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
// Fallback component for unknown types
|
||||
const fallbackRegistry = (renderProps: { element: { type: string } }) => (
|
||||
<Fallback type={renderProps.element.type} />
|
||||
);
|
||||
|
||||
export function PlaygroundRenderer({
|
||||
spec,
|
||||
data,
|
||||
loading,
|
||||
}: PlaygroundRendererProps): ReactNode {
|
||||
// Memoize registry to prevent re-creating on every render
|
||||
const registry = useMemo(() => buildRegistry(loading), [loading]);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<DataProvider initialData={data}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider>
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
fallback={fallbackRegistry}
|
||||
loading={loading}
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import type { MDXComponents } from "mdx/types";
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
import { PackageInstall } from "@/components/package-install";
|
||||
|
||||
function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractText(children: React.ReactNode): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (typeof children === "number") return String(children);
|
||||
if (Array.isArray(children)) return children.map(extractText).join("");
|
||||
if (children && typeof children === "object") {
|
||||
const obj = children as unknown as Record<string, unknown>;
|
||||
if ("props" in obj) {
|
||||
const props = obj.props as { children?: React.ReactNode } | undefined;
|
||||
return extractText(props?.children);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function useMDXComponents(components: MDXComponents): MDXComponents {
|
||||
return {
|
||||
...components,
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="text-3xl font-bold mb-4">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return (
|
||||
<h2 id={id} className="text-xl font-semibold mt-12 mb-4">
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
},
|
||||
h3: ({ children }: { children?: React.ReactNode }) => {
|
||||
const id = slugify(extractText(children));
|
||||
return (
|
||||
<h3 id={id} className="text-lg font-medium mt-8 mb-3">
|
||||
{children}
|
||||
</h3>
|
||||
);
|
||||
},
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground mb-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: { children?: React.ReactNode }) => <li>{children}</li>,
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (href?.startsWith("/")) {
|
||||
return (
|
||||
<Link href={href} className="text-foreground hover:underline">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="text-foreground hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code: ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
// Fenced code blocks come through as <pre><code className="language-xxx">
|
||||
// Inline code has no className
|
||||
if (className) {
|
||||
// This is a fenced code block inside <pre> - handled by the pre component
|
||||
return <code className={className}>{children}</code>;
|
||||
}
|
||||
return (
|
||||
<code className="text-foreground bg-muted px-1.5 py-0.5 rounded text-sm">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
pre: async ({ children }: { children?: React.ReactNode }) => {
|
||||
// Extract lang and code from the <code> child
|
||||
const codeElement = children as React.ReactElement<{
|
||||
className?: string;
|
||||
children?: string;
|
||||
}>;
|
||||
const className = codeElement?.props?.className || "";
|
||||
const lang = className.replace("language-", "") || "typescript";
|
||||
const code = codeElement?.props?.children || "";
|
||||
|
||||
return (
|
||||
<Code
|
||||
lang={lang as "json" | "tsx" | "typescript" | "bash" | "javascript"}
|
||||
>
|
||||
{typeof code === "string" ? code : String(code)}
|
||||
</Code>
|
||||
);
|
||||
},
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="text-foreground font-medium">{children}</strong>
|
||||
),
|
||||
hr: () => <hr className="my-8 border-border" />,
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="border-l-2 border-border pl-4 my-4 text-sm text-muted-foreground italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
table: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div className="my-6 overflow-x-auto">
|
||||
<table className="w-full text-sm border-collapse">{children}</table>
|
||||
</div>
|
||||
),
|
||||
th: ({ children }: { children?: React.ReactNode }) => (
|
||||
<th className="border border-border px-4 py-2 text-left font-semibold bg-muted">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: { children?: React.ReactNode }) => (
|
||||
<td className="border border-border px-4 py-2 text-muted-foreground">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => <em>{children}</em>,
|
||||
// Custom components available in all MDX files
|
||||
PackageInstall,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
import createMDX from "@next/mdx";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ["bash-tool", "just-bash", "@mongodb-js/zstd"],
|
||||
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
@@ -15,6 +11,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
export default nextConfig;
|
||||
|
||||
@@ -13,14 +13,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"@ai-sdk/react": "3.0.79",
|
||||
"@json-render/codegen": "workspace:*",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@mdx-js/loader": "^3.1.1",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@next/mdx": "^16.1.6",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
@@ -29,11 +24,8 @@
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.33",
|
||||
"bash-tool": "1.3.14",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"just-bash": "2.9.6",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -43,16 +35,13 @@
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "2.1.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/eslint-config": "workspace:*",
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { streamText } from "ai";
|
||||
import { buildUserPrompt } from "@json-render/core";
|
||||
import { dashboardCatalog } from "@/lib/render/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
// Use the new catalog.prompt() API
|
||||
const SYSTEM_PROMPT = dashboardCatalog.prompt();
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
@@ -11,15 +11,17 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, context } = await req.json();
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
state: context?.data,
|
||||
});
|
||||
let fullPrompt = prompt;
|
||||
|
||||
// Add data context
|
||||
if (context?.data) {
|
||||
fullPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(context.data, null, 2)}`;
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
prompt: fullPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Widget({
|
||||
dragHandleProps,
|
||||
}: WidgetProps): React.ReactElement {
|
||||
const [prompt, setPrompt] = useState(initialPrompt || "");
|
||||
const [state, setState] = useState<Record<string, unknown>>({});
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [widgetId, setWidgetId] = useState<string | undefined>(initialId);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
@@ -65,14 +65,14 @@ export function Widget({
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const promptRef = useRef(prompt);
|
||||
const stateRef = useRef(state);
|
||||
const dataRef = useRef(data);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const promptInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep stateRef in sync
|
||||
// Keep dataRef in sync
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
// Auto-focus prompt input for new widgets
|
||||
useEffect(() => {
|
||||
@@ -130,17 +130,17 @@ export function Widget({
|
||||
const p = text || prompt;
|
||||
if (!p.trim()) return;
|
||||
if (text) setPrompt(text);
|
||||
await send(p, { state });
|
||||
await send(p, { data });
|
||||
onGenerated?.();
|
||||
},
|
||||
[prompt, send, state, onGenerated],
|
||||
[prompt, send, data, onGenerated],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleClear = useCallback(() => {
|
||||
clear();
|
||||
setPrompt("");
|
||||
setState({});
|
||||
setData({});
|
||||
onCleared?.();
|
||||
}, [clear, onCleared]);
|
||||
|
||||
@@ -165,10 +165,10 @@ export function Widget({
|
||||
setIsEditing(false);
|
||||
// Pass the current spec so AI can modify it instead of replacing
|
||||
const existingSpec = spec || initialSpec;
|
||||
await send(editPrompt, { state, previousSpec: existingSpec });
|
||||
await send(editPrompt, { data, previousSpec: existingSpec });
|
||||
onGenerated?.();
|
||||
}
|
||||
}, [editPrompt, send, state, spec, initialSpec, onGenerated]);
|
||||
}, [editPrompt, send, data, spec, initialSpec, onGenerated]);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -196,8 +196,8 @@ export function Widget({
|
||||
onDeleted?.();
|
||||
}, [widgetId, onDeleted]);
|
||||
|
||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
||||
setState((prev) => {
|
||||
const handleDataChange = useCallback((path: string, value: unknown) => {
|
||||
setData((prev) => {
|
||||
const next = { ...prev };
|
||||
// Convert path like "customerForm/name" to nested object
|
||||
const parts = path.split("/");
|
||||
@@ -231,7 +231,7 @@ export function Widget({
|
||||
};
|
||||
if (specWithMeta.initialActions) {
|
||||
specWithMeta.initialActions.forEach(({ action, params }) => {
|
||||
executeAction(action, params, setState);
|
||||
executeAction(action, params, setData);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -243,10 +243,10 @@ export function Widget({
|
||||
|
||||
for (const el of Object.values(elements)) {
|
||||
const element = el as { type: string; props?: Record<string, unknown> };
|
||||
if (dataComponents.includes(element.type) && element.props?.statePath) {
|
||||
const statePath = element.props.statePath as string;
|
||||
if (dataComponents.includes(element.type) && element.props?.dataPath) {
|
||||
const dataPath = element.props.dataPath as string;
|
||||
// Extract resource name (e.g., "customers" from "customers.data")
|
||||
const resource = statePath.split(".")[0];
|
||||
const resource = dataPath.split(".")[0];
|
||||
if (!resource || actionsRun.has(resource)) continue;
|
||||
// Find a button that loads this data
|
||||
for (const btnEl of Object.values(elements)) {
|
||||
@@ -266,7 +266,7 @@ export function Widget({
|
||||
executeAction(
|
||||
action,
|
||||
btn.props.actionParams as Record<string, unknown>,
|
||||
setState,
|
||||
setData,
|
||||
);
|
||||
actionsRun.add(resource);
|
||||
break;
|
||||
@@ -387,9 +387,9 @@ export function Widget({
|
||||
) : (
|
||||
<DashboardRenderer
|
||||
spec={currentSpec}
|
||||
state={state}
|
||||
setState={setState}
|
||||
onStateChange={handleStateChange}
|
||||
data={data}
|
||||
setData={setData}
|
||||
onDataChange={handleDataChange}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -277,7 +277,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
statePath: z.string(),
|
||||
dataPath: z.string(),
|
||||
columns: z.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
@@ -327,7 +327,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
BarChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
dataPath: z.string(),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -335,13 +335,13 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Bar chart visualization. statePath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
"Bar chart visualization. dataPath points to array of objects, xKey is the category/group field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey (yKey becomes the count). For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
statePath: z.string(),
|
||||
dataPath: z.string(),
|
||||
xKey: z.string(),
|
||||
yKey: z.string(),
|
||||
aggregate: z.enum(["sum", "count", "avg"]).nullable(),
|
||||
@@ -349,7 +349,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Line chart visualization. statePath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
"Line chart visualization. dataPath points to array of objects, xKey is the x-axis field, yKey is the numeric value field. Use aggregate='count' to count items grouped by xKey. For dates, xKey values are auto-formatted.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { findFormValue, getByPath } from "@json-render/core";
|
||||
import { useStateStore, defineRegistry } from "@json-render/react";
|
||||
import { useData, defineRegistry } from "@json-render/react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
@@ -132,24 +132,29 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</AccordionItem>
|
||||
),
|
||||
|
||||
Button: ({ props, emit, loading }) => (
|
||||
Button: ({ props, onAction, loading }) => (
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
disabled={loading || (props.disabled ?? false)}
|
||||
onClick={() => emit?.("press")}
|
||||
onClick={() =>
|
||||
onAction?.({
|
||||
name: props.action,
|
||||
params: props.actionParams ?? undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
Input: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const { data, set } = useData();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={(getByPath(data, props.valuePath) as string) ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
/>
|
||||
@@ -157,11 +162,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Form: ({ children, emit }) => (
|
||||
Form: ({ props, children, onAction }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
emit?.("submit");
|
||||
onAction?.({
|
||||
name: props.submitAction,
|
||||
params: props.submitActionParams ?? undefined,
|
||||
});
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -194,9 +202,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Checkbox: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const { data, set } = useData();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
(getByPath(data, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
return (
|
||||
@@ -247,14 +255,26 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</Drawer>
|
||||
),
|
||||
|
||||
DropdownMenu: ({ props }) => (
|
||||
DropdownMenu: ({ props, onAction }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{props.items.map((item, i) => (
|
||||
<DropdownMenuItem key={i}>{item.label}</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onClick={() =>
|
||||
item.action
|
||||
? onAction?.({
|
||||
name: item.action,
|
||||
params: item.actionParams ?? undefined,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -304,9 +324,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
RadioGroup: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const { data, set } = useData();
|
||||
const value =
|
||||
(getByPath(state, props.valuePath) as string) ??
|
||||
(getByPath(data, props.valuePath) as string) ??
|
||||
props.defaultValue ??
|
||||
"";
|
||||
return (
|
||||
@@ -325,8 +345,8 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
Select: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const value = (getByPath(state, props.valuePath) as string) ?? "";
|
||||
const { data, set } = useData();
|
||||
const value = (getByPath(data, props.valuePath) as string) ?? "";
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
|
||||
<SelectTrigger>
|
||||
@@ -360,9 +380,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
Switch: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const { data, set } = useData();
|
||||
const checked =
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
(getByPath(data, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
return (
|
||||
@@ -397,12 +417,12 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const { state, set } = useStateStore();
|
||||
const { data, set } = useData();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Textarea
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
value={(getByPath(data, props.valuePath) as string) ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
@@ -429,10 +449,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</p>
|
||||
),
|
||||
|
||||
Table: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
Table: ({ props, onAction }) => {
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -482,6 +502,12 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
key={rowAction.action}
|
||||
variant={rowAction.variant ?? "ghost"}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
onAction?.({
|
||||
name: rowAction.action,
|
||||
params: { id: item.id as string },
|
||||
})
|
||||
}
|
||||
>
|
||||
{rowAction.label}
|
||||
</Button>
|
||||
@@ -497,9 +523,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -564,9 +590,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -634,7 +660,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
actions: {
|
||||
viewCustomers: async (params, setState) => {
|
||||
viewCustomers: async (params, setData) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.set("limit", String(params.limit));
|
||||
if (params?.sort) queryParams.set("sort", String(params.sort));
|
||||
@@ -642,25 +668,25 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const customers = await res.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
refreshCustomers: async (params, setState) => {
|
||||
refreshCustomers: async (params, setData) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.set("limit", String(params.limit));
|
||||
if (params?.sort) queryParams.set("sort", String(params.sort));
|
||||
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const customers = await res.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
createCustomer: async (params, setState, state) => {
|
||||
const name = findFormValue("name", params, state) as string;
|
||||
createCustomer: async (params, setData, data) => {
|
||||
const name = findFormValue("name", params, data) as string;
|
||||
const email =
|
||||
(findFormValue("email", params, state) as string) ||
|
||||
(findFormValue("email", params, data) as string) ||
|
||||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
|
||||
const phone = findFormValue("phone", params, state) as
|
||||
const phone = findFormValue("phone", params, data) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -680,7 +706,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success(`Customer "${customer.name}" created`);
|
||||
const listRes = await fetch("/api/v1/customers");
|
||||
const customers = await listRes.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
toast.error(customer.error || "Failed to create customer");
|
||||
}
|
||||
@@ -690,10 +716,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomer: async (params, setState, state) => {
|
||||
deleteCustomer: async (params, setData, data) => {
|
||||
const customerId =
|
||||
findFormValue("customerId", params, state) ||
|
||||
findFormValue("id", params, state);
|
||||
findFormValue("customerId", params, data) ||
|
||||
findFormValue("id", params, data);
|
||||
if (!customerId) {
|
||||
toast.error("Customer ID required");
|
||||
return;
|
||||
@@ -706,7 +732,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Customer deleted");
|
||||
const listRes = await fetch("/api/v1/customers");
|
||||
const customers = await listRes.json();
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || "Failed to delete customer");
|
||||
@@ -717,22 +743,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
viewInvoices: async (params, setState) => {
|
||||
viewInvoices: async (params, setData) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set("status", String(params.status));
|
||||
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const invoices = await res.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
refreshInvoices: async (_params, setState) => {
|
||||
refreshInvoices: async (_params, setData) => {
|
||||
const res = await fetch("/api/v1/invoices");
|
||||
const invoices = await res.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
createInvoice: async (params, setState) => {
|
||||
createInvoice: async (params, setData) => {
|
||||
if (!params?.customerId || !params?.dueDate) {
|
||||
toast.error("Customer ID and due date required");
|
||||
return;
|
||||
@@ -748,7 +774,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Invoice created");
|
||||
const listRes = await fetch("/api/v1/invoices");
|
||||
const invoices = await listRes.json();
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
} else {
|
||||
toast.error(invoice.error || "Failed to create invoice");
|
||||
}
|
||||
@@ -791,22 +817,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
viewExpenses: async (params, setState) => {
|
||||
viewExpenses: async (params, setData) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.status) queryParams.set("status", String(params.status));
|
||||
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
|
||||
const res = await fetch(url);
|
||||
const expenses = await res.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
refreshExpenses: async (_params, setState) => {
|
||||
refreshExpenses: async (_params, setData) => {
|
||||
const res = await fetch("/api/v1/expenses");
|
||||
const expenses = await res.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
createExpense: async (params, setState) => {
|
||||
createExpense: async (params, setData) => {
|
||||
if (
|
||||
!params?.vendor ||
|
||||
!params?.category ||
|
||||
@@ -826,7 +852,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Expense created");
|
||||
const listRes = await fetch("/api/v1/expenses");
|
||||
const expenses = await listRes.json();
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
} else {
|
||||
toast.error(expense.error || "Failed to create expense");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Renderer,
|
||||
type ComponentRenderer,
|
||||
type Spec,
|
||||
StateProvider,
|
||||
DataProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
@@ -16,15 +16,15 @@ import { registry, Fallback, handlers as createHandlers } from "./registry";
|
||||
// DashboardRenderer
|
||||
// =============================================================================
|
||||
|
||||
type SetState = (
|
||||
type SetData = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
interface DashboardRendererProps {
|
||||
spec: Spec | null;
|
||||
state?: Record<string, unknown>;
|
||||
setState?: SetState;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
data?: Record<string, unknown>;
|
||||
setData?: SetData;
|
||||
onDataChange?: (path: string, value: unknown) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,24 +35,24 @@ const fallback: ComponentRenderer = ({ element }) => (
|
||||
|
||||
export function DashboardRenderer({
|
||||
spec,
|
||||
state = {},
|
||||
setState,
|
||||
onStateChange,
|
||||
data = {},
|
||||
setData,
|
||||
onDataChange,
|
||||
loading,
|
||||
}: DashboardRendererProps): ReactNode {
|
||||
// Use refs so action handlers always see the latest state/setState
|
||||
const stateRef = useRef(state);
|
||||
const setStateRef = useRef(setState);
|
||||
stateRef.current = state;
|
||||
setStateRef.current = setState;
|
||||
// Use refs so action handlers always see the latest data/setData
|
||||
const dataRef = useRef(data);
|
||||
const setDataRef = useRef(setData);
|
||||
dataRef.current = data;
|
||||
setDataRef.current = setData;
|
||||
|
||||
// Create ActionProvider-compatible handlers using getters so they
|
||||
// always read the latest state/setState from refs
|
||||
// always read the latest data/setData from refs
|
||||
const actionHandlers = useMemo(
|
||||
() =>
|
||||
createHandlers(
|
||||
() => setStateRef.current,
|
||||
() => stateRef.current,
|
||||
() => setDataRef.current,
|
||||
() => dataRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export function DashboardRenderer({
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<DataProvider initialData={data} onDataChange={onDataChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
@@ -71,6 +71,6 @@ export function DashboardRenderer({
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Vercel AI Gateway
|
||||
# Automatically authenticated when deployed on Vercel
|
||||
# For local development, get your key from https://vercel.com/ai-gateway
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# AI Model Configuration
|
||||
# Override the default model used for UI generation
|
||||
# Default: anthropic/claude-haiku-4.5
|
||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "json-render-rn",
|
||||
"slug": "json-render-rn",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"scheme": "json-render-rn",
|
||||
"userInterfaceStyle": "light",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.example.jsonrenderrn"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": "com.example.jsonrenderrn"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "server"
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Stack } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="dark" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#ffffff" },
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { streamText } from "ai";
|
||||
import { createGatewayProvider } from "@ai-sdk/gateway";
|
||||
import { buildUserPrompt } from "@json-render/core";
|
||||
import { catalog, customRules } from "../../lib/render/catalog";
|
||||
|
||||
const SYSTEM_PROMPT = catalog.prompt({ customRules });
|
||||
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
// Explicitly pass the API key since Expo's Metro bundler only inlines
|
||||
// process.env values that are directly referenced in application code.
|
||||
// The default `gateway` singleton reads process.env.AI_GATEWAY_API_KEY
|
||||
// internally, which Metro won't bundle.
|
||||
const gateway = createGatewayProvider({
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY,
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
console.log("[API] POST /api/generate called");
|
||||
console.log("[API] API key present:", !!process.env.AI_GATEWAY_API_KEY);
|
||||
console.log("[API] Model:", process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL);
|
||||
|
||||
try {
|
||||
const { prompt, context } = await req.json();
|
||||
console.log("[API] prompt:", prompt);
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: context?.previousSpec,
|
||||
state: context?.state,
|
||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||
});
|
||||
|
||||
const modelId = process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL;
|
||||
const model = gateway(modelId);
|
||||
|
||||
console.log("[API] calling streamText with model:", modelId);
|
||||
const result = streamText({
|
||||
model,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
console.log("[API] returning text stream response");
|
||||
return result.toTextStreamResponse();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error("API generate error:", message);
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user