mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 20:08:16 +08:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edbeb5a637 | ||
|
|
d9a4efdbeb | ||
|
|
f435643817 | ||
|
|
458f3a728c | ||
|
|
d5734e975c | ||
|
|
3d2d1adb2d | ||
|
|
801708a128 | ||
|
|
e9ea9c782b | ||
|
|
dd17549ee8 | ||
|
|
1eb6212dd7 | ||
|
|
711e79b069 | ||
|
|
f3611860a7 | ||
|
|
61ee8e5291 | ||
|
|
d38b281e5e | ||
|
|
39fcc192e9 |
@@ -6,6 +6,7 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-native",
|
||||
"@json-render/remotion",
|
||||
"@json-render/codegen"
|
||||
]
|
||||
|
||||
@@ -21,11 +21,15 @@ coverage
|
||||
# Vercel
|
||||
.vercel
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Build Outputs
|
||||
.next/
|
||||
out/
|
||||
build
|
||||
dist
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
# Debug
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
# json-render
|
||||
|
||||
**Predictable. Guardrailed. Fast.**
|
||||
**The framework for User-Generated Interfaces (UGI).**
|
||||
|
||||
Let end users generate dashboards, widgets, apps, and videos from prompts — safely constrained to components you define.
|
||||
Dynamic, personalized UIs per user without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react
|
||||
# or for mobile
|
||||
npm install @json-render/core @json-render/react-native
|
||||
# or for video
|
||||
npm install @json-render/core @json-render/remotion
|
||||
```
|
||||
|
||||
## Why json-render?
|
||||
|
||||
When users prompt for UI, you need guarantees. json-render gives AI a **constrained vocabulary** so output is always predictable:
|
||||
json-render enables **User-Generated Interfaces**: dynamic UIs that end users create through natural language prompts, powered by Generative UI. You define the guardrails, AI generates within them:
|
||||
|
||||
- **Guardrailed** — AI can only use components in your catalog
|
||||
- **Predictable** — JSON output matches your schema, every time
|
||||
- **Fast** — Stream and render progressively as the model responds
|
||||
- **Guardrailed** - AI can only use components in your catalog
|
||||
- **Predictable** - JSON output matches your schema, every time
|
||||
- **Fast** - Stream and render progressively as the model responds
|
||||
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -75,8 +78,8 @@ const { registry } = defineRegistry(catalog, {
|
||||
<span>{format(props.value, props.format)}</span>
|
||||
</div>
|
||||
),
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
@@ -100,8 +103,9 @@ function Dashboard({ spec }) {
|
||||
|
||||
| Package | Description |
|
||||
|---------|-------------|
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
|
||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||
| `@json-render/react` | React renderer, contexts, hooks |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
|
||||
## Renderers
|
||||
@@ -128,6 +132,27 @@ const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### React Native (Mobile)
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react-native/schema";
|
||||
import {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
} from "@json-render/react-native/catalog";
|
||||
import { defineRegistry, Renderer } from "@json-render/react-native";
|
||||
|
||||
// 25+ standard components included
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { ...standardComponentDefinitions },
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components: {} });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
### Remotion (Video)
|
||||
|
||||
```tsx
|
||||
@@ -197,18 +222,39 @@ const systemPrompt = catalog.prompt();
|
||||
}
|
||||
```
|
||||
|
||||
### Data Binding
|
||||
### Dynamic Props
|
||||
|
||||
Any prop value can be data-driven using expressions:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"type": "Icon",
|
||||
"props": {
|
||||
"label": "Revenue",
|
||||
"value": "{{data.revenue}}"
|
||||
"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" },
|
||||
"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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 state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
||||
|
||||
---
|
||||
|
||||
## Demo
|
||||
@@ -220,9 +266,10 @@ pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
- http://localhost:3000 — Docs & Playground
|
||||
- http://localhost:3001 — Example Dashboard
|
||||
- http://localhost:3002 — Remotion Video Example
|
||||
- http://localhost:3000 - Docs & Playground
|
||||
- http://localhost:3001 - Example Dashboard
|
||||
- http://localhost:3002 - Remotion Video Example
|
||||
- React Native example: run `npx expo start` in `examples/react-native`
|
||||
|
||||
## How It Works
|
||||
|
||||
@@ -237,10 +284,10 @@ flowchart LR
|
||||
D -.- G([streamed])
|
||||
```
|
||||
|
||||
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
|
||||
1. **Define the guardrails** - what components, actions, and data bindings AI can use
|
||||
2. **Users generate** - 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
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,49 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
export const metadata = { title: "A2UI Integration" }
|
||||
|
||||
export const metadata = {
|
||||
title: "A2UI Integration | json-render",
|
||||
};
|
||||
# A2UI Integration
|
||||
|
||||
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>
|
||||
Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can
|
||||
support A2UI. The examples are illustrative and may require adaptation
|
||||
for production use.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can support A2UI. The examples are illustrative and may require adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/core</code> is
|
||||
schema-agnostic. Define a catalog that matches A2UI's format and
|
||||
build a renderer that understands it - no conversion layer needed.
|
||||
</p>
|
||||
## Native A2UI Support
|
||||
|
||||
<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">{`{
|
||||
`@json-render/core` is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed.
|
||||
|
||||
## Example A2UI Message
|
||||
|
||||
A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components:
|
||||
|
||||
```json
|
||||
{
|
||||
"surfaceUpdate": {
|
||||
"surfaceId": "main",
|
||||
"components": [
|
||||
@@ -83,12 +59,13 @@ export default function A2UIPage() {
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}</Code>
|
||||
}
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the A2UI Catalog
|
||||
</h2>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
## Define the A2UI Catalog
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
// A2UI BoundValue schema
|
||||
@@ -151,15 +128,15 @@ export const a2uiCatalog = createCatalog({
|
||||
},
|
||||
// Add more A2UI standard components...
|
||||
},
|
||||
});`}</Code>
|
||||
});
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define the A2UI Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define the schema for A2UI message types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
## Define the A2UI Schema
|
||||
|
||||
Define the schema for A2UI message types:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Component instance in the adjacency list
|
||||
const A2UIComponent = z.object({
|
||||
@@ -173,8 +150,8 @@ const SurfaceUpdate = z.object({
|
||||
components: z.array(A2UIComponent),
|
||||
});
|
||||
|
||||
// Data model update message
|
||||
const DataModelUpdate = z.object({
|
||||
// State model update message
|
||||
const StateModelUpdate = z.object({
|
||||
surfaceId: z.string().optional(),
|
||||
path: z.string().optional(),
|
||||
contents: z.array(z.object({
|
||||
@@ -196,18 +173,18 @@ const BeginRendering = z.object({
|
||||
// Complete A2UI message schema
|
||||
export const A2UIMessage = z.object({
|
||||
surfaceUpdate: SurfaceUpdate.optional(),
|
||||
dataModelUpdate: DataModelUpdate.optional(),
|
||||
dataModelUpdate: StateModelUpdate.optional(),
|
||||
beginRendering: BeginRendering.optional(),
|
||||
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
|
||||
});`}</Code>
|
||||
});
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Build an A2UI Renderer
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a renderer that processes the A2UI adjacency list format:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { a2uiCatalog } from './catalog';
|
||||
## Build an A2UI Renderer
|
||||
|
||||
Create a renderer that processes the A2UI adjacency list format:
|
||||
|
||||
```tsx
|
||||
import { a2uiCatalog } from './catalog';
|
||||
|
||||
// Component registry
|
||||
const components = {
|
||||
@@ -239,7 +216,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;
|
||||
@@ -272,10 +249,13 @@ export function renderA2UI(
|
||||
}
|
||||
|
||||
return render(rootId);
|
||||
}`}</Code>
|
||||
}
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
|
||||
<Code lang="tsx">{`const [components] = useState(() => new Map());
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
const [components] = useState(() => new Map());
|
||||
const [dataModel, setDataModel] = useState({});
|
||||
const [rootId, setRootId] = useState<string | null>(null);
|
||||
|
||||
@@ -295,19 +275,9 @@ function handleMessage(msg: any) {
|
||||
}
|
||||
|
||||
// Render
|
||||
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
|
||||
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}
|
||||
```
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
## Next
|
||||
|
||||
Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol.
|
||||
@@ -0,0 +1,406 @@
|
||||
export const metadata = { title: "Adaptive Cards Integration" }
|
||||
|
||||
# Adaptive Cards Integration
|
||||
|
||||
Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can support Adaptive Cards. The examples are illustrative and may require adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Adaptive Cards Overview
|
||||
|
||||
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards have a `body` array of elements and an optional `actions` array for interactive buttons.
|
||||
|
||||
### Example Adaptive Card
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
"type": "AdaptiveCard",
|
||||
"version": "1.5",
|
||||
"body": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "Hello, Adaptive Cards!",
|
||||
"size": "large",
|
||||
"weight": "bolder"
|
||||
},
|
||||
{
|
||||
"type": "Image",
|
||||
"url": "https://example.com/image.png",
|
||||
"altText": "Example image"
|
||||
},
|
||||
{
|
||||
"type": "Container",
|
||||
"items": [
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": "This is inside a container",
|
||||
"wrap": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "ColumnSet",
|
||||
"columns": [
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "auto",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "Column 1" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Column",
|
||||
"width": "stretch",
|
||||
"items": [
|
||||
{ "type": "TextBlock", "text": "Column 2" }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
"id": "userInput",
|
||||
"placeholder": "Enter your name",
|
||||
"label": "Name"
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"type": "Action.Submit",
|
||||
"title": "Submit"
|
||||
},
|
||||
{
|
||||
"type": "Action.OpenUrl",
|
||||
"title": "Learn More",
|
||||
"url": "https://adaptivecards.io"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Creating an Adaptive Cards Catalog
|
||||
|
||||
Define a catalog matching the Adaptive Cards element types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Common Adaptive Cards properties
|
||||
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
|
||||
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
|
||||
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
|
||||
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
|
||||
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
|
||||
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
|
||||
const ImageStyle = z.enum(['default', 'person']);
|
||||
|
||||
// Base element properties shared by most elements
|
||||
const BaseElement = {
|
||||
id: z.string().optional(),
|
||||
isVisible: z.boolean().optional(),
|
||||
separator: z.boolean().optional(),
|
||||
spacing: Spacing.optional(),
|
||||
};
|
||||
|
||||
export const adaptiveCardsCatalog = createCatalog({
|
||||
components: {
|
||||
// Root card
|
||||
AdaptiveCard: {
|
||||
description: 'Root Adaptive Card container',
|
||||
props: z.object({
|
||||
version: z.string(),
|
||||
body: z.array(z.unknown()).optional(),
|
||||
actions: z.array(z.unknown()).optional(),
|
||||
fallbackText: z.string().optional(),
|
||||
minHeight: z.string().optional(),
|
||||
rtl: z.boolean().optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Elements
|
||||
TextBlock: {
|
||||
description: 'Displays text with formatting options',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
text: z.string(),
|
||||
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
|
||||
fontType: z.enum(['default', 'monospace']).optional(),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
isSubtle: z.boolean().optional(),
|
||||
maxLines: z.number().optional(),
|
||||
size: FontSize.optional(),
|
||||
weight: FontWeight.optional(),
|
||||
wrap: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Image: {
|
||||
description: 'Displays an image',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
url: z.string(),
|
||||
altText: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
height: z.string().optional(),
|
||||
width: z.string().optional(),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
size: ImageSize.optional(),
|
||||
style: ImageStyle.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Container: {
|
||||
description: 'Groups elements together',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
items: z.array(z.unknown()),
|
||||
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
bleed: z.boolean().optional(),
|
||||
minHeight: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ColumnSet: {
|
||||
description: 'Arranges columns horizontally',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
columns: z.array(z.unknown()),
|
||||
horizontalAlignment: HorizontalAlignment.optional(),
|
||||
minHeight: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
Column: {
|
||||
description: 'A column within a ColumnSet',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
items: z.array(z.unknown()).optional(),
|
||||
width: z.union([z.string(), z.number()]).optional(),
|
||||
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
|
||||
verticalContentAlignment: VerticalAlignment.optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
FactSet: {
|
||||
description: 'Displays a series of facts as key/value pairs',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
facts: z.array(z.object({
|
||||
title: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
}),
|
||||
},
|
||||
|
||||
// Inputs
|
||||
'Input.Text': {
|
||||
description: 'Text input field',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
isMultiline: z.boolean().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Number': {
|
||||
description: 'Number input field',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
max: z.number().optional(),
|
||||
min: z.number().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.number().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.Toggle': {
|
||||
description: 'Toggle/checkbox input',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
valueOff: z.string().optional(),
|
||||
valueOn: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Input.ChoiceSet': {
|
||||
description: 'Dropdown or radio/checkbox group',
|
||||
props: z.object({
|
||||
...BaseElement,
|
||||
id: z.string(),
|
||||
choices: z.array(z.object({
|
||||
title: z.string(),
|
||||
value: z.string(),
|
||||
})),
|
||||
isMultiSelect: z.boolean().optional(),
|
||||
style: z.enum(['compact', 'expanded']).optional(),
|
||||
label: z.string().optional(),
|
||||
value: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
isRequired: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Actions
|
||||
'Action.OpenUrl': {
|
||||
description: 'Opens a URL',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
url: z.string(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.Submit': {
|
||||
description: 'Submits input data',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.ShowCard': {
|
||||
description: 'Shows a card inline',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
card: z.unknown(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
'Action.Execute': {
|
||||
description: 'Universal action for bots',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
verb: z.string().optional(),
|
||||
data: z.unknown().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Building an Adaptive Cards Renderer
|
||||
|
||||
Create a renderer that processes Adaptive Cards JSON. See the [A2UI integration](/docs/a2ui) page for a similar pattern. The key is mapping each Adaptive Card element type to a React component, resolving nested `items` and `columns` arrays recursively.
|
||||
|
||||
## Usage Example
|
||||
|
||||
Render an Adaptive Card and handle actions:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
|
||||
|
||||
const card = {
|
||||
type: 'AdaptiveCard' as const,
|
||||
version: '1.5',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
text: 'Contact Form',
|
||||
size: 'large',
|
||||
weight: 'bolder',
|
||||
},
|
||||
{
|
||||
type: 'Input.Text',
|
||||
id: 'name',
|
||||
label: 'Your Name',
|
||||
placeholder: 'Enter your name',
|
||||
},
|
||||
{
|
||||
type: 'Input.Text',
|
||||
id: 'message',
|
||||
label: 'Message',
|
||||
placeholder: 'Enter your message',
|
||||
isMultiline: true,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
type: 'Action.Submit',
|
||||
title: 'Send',
|
||||
data: { action: 'submitForm' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export function ContactCard() {
|
||||
const handleAction = (action: any, inputData: Record<string, unknown>) => {
|
||||
console.log('Action:', action);
|
||||
console.log('Input data:', inputData);
|
||||
|
||||
// Send to your backend
|
||||
fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, data: inputData }),
|
||||
});
|
||||
};
|
||||
|
||||
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Handling Action.Execute for Bots
|
||||
|
||||
For bot scenarios, handle `Action.Execute` with the verb and data:
|
||||
|
||||
```typescript
|
||||
interface ActionExecutePayload {
|
||||
action: {
|
||||
type: 'Action.Execute';
|
||||
verb: string;
|
||||
data?: unknown;
|
||||
};
|
||||
inputs: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function handleBotAction(payload: ActionExecutePayload) {
|
||||
const response = await fetch('/api/bot/action', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
verb: payload.action.verb,
|
||||
data: payload.action.data,
|
||||
inputs: payload.inputs,
|
||||
}),
|
||||
});
|
||||
|
||||
// Bot may return a new card to render
|
||||
const result = await response.json();
|
||||
if (result.card) {
|
||||
return result.card; // New AdaptiveCard to render
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [A2UI integration](/docs/a2ui) for another agent-driven UI protocol.
|
||||
@@ -1,791 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
export const metadata = { title: "AG-UI Integration" }
|
||||
|
||||
# AG-UI Integration
|
||||
|
||||
Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can support AG-UI. The examples are illustrative and may require adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## What is AG-UI?
|
||||
|
||||
AG-UI is an open protocol for connecting AI agents to user interfaces. It provides a standardized way for agents to render UI components, handle user input, and manage state. The protocol uses events streamed over HTTP to update the UI in real-time.
|
||||
|
||||
## AG-UI Event Types
|
||||
|
||||
AG-UI defines several event types for agent-UI communication:
|
||||
|
||||
- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` — Streaming text messages
|
||||
- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` — Tool/function calls
|
||||
- `STATE_SNAPSHOT` / `STATE_DELTA` — State updates
|
||||
- `CUSTOM` — Custom events for UI rendering
|
||||
|
||||
### Example AG-UI Event Stream
|
||||
|
||||
```json
|
||||
{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
|
||||
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
|
||||
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
|
||||
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
|
||||
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
|
||||
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\"component\": \"Dashboard\", \"props\": {\"title\": \"Sales\"}}"}
|
||||
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
|
||||
{"type": "RUN_FINISHED"}
|
||||
```
|
||||
|
||||
## Define the AG-UI Schema
|
||||
|
||||
Define schemas for AG-UI event types:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Base event schema
|
||||
const BaseEvent = z.object({
|
||||
type: z.string(),
|
||||
timestamp: z.number().optional(),
|
||||
});
|
||||
|
||||
// Text message events
|
||||
const TextMessageStart = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_START'),
|
||||
messageId: z.string(),
|
||||
role: z.enum(['user', 'assistant']),
|
||||
});
|
||||
|
||||
const TextMessageContent = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_CONTENT'),
|
||||
messageId: z.string(),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
const TextMessageEnd = BaseEvent.extend({
|
||||
type: z.literal('TEXT_MESSAGE_END'),
|
||||
messageId: z.string(),
|
||||
});
|
||||
|
||||
// Tool call events
|
||||
const ToolCallStart = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_START'),
|
||||
toolCallId: z.string(),
|
||||
toolCallName: z.string(),
|
||||
parentMessageId: z.string().optional(),
|
||||
});
|
||||
|
||||
const ToolCallArgs = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_ARGS'),
|
||||
toolCallId: z.string(),
|
||||
delta: z.string(),
|
||||
});
|
||||
|
||||
const ToolCallEnd = BaseEvent.extend({
|
||||
type: z.literal('TOOL_CALL_END'),
|
||||
toolCallId: z.string(),
|
||||
});
|
||||
|
||||
// State events
|
||||
const StateSnapshot = BaseEvent.extend({
|
||||
type: z.literal('STATE_SNAPSHOT'),
|
||||
snapshot: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
const StateDelta = BaseEvent.extend({
|
||||
type: z.literal('STATE_DELTA'),
|
||||
delta: z.array(z.object({
|
||||
op: z.enum(['add', 'remove', 'replace']),
|
||||
path: z.string(),
|
||||
value: z.unknown().optional(),
|
||||
})),
|
||||
});
|
||||
|
||||
// Custom event for UI components
|
||||
const CustomEvent = BaseEvent.extend({
|
||||
type: z.literal('CUSTOM'),
|
||||
name: z.string(),
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
// Run lifecycle events
|
||||
const RunStarted = BaseEvent.extend({
|
||||
type: z.literal('RUN_STARTED'),
|
||||
threadId: z.string(),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
const RunFinished = BaseEvent.extend({
|
||||
type: z.literal('RUN_FINISHED'),
|
||||
});
|
||||
|
||||
const RunError = BaseEvent.extend({
|
||||
type: z.literal('RUN_ERROR'),
|
||||
message: z.string(),
|
||||
code: z.string().optional(),
|
||||
});
|
||||
|
||||
// Union of all events
|
||||
export const AGUIEvent = z.discriminatedUnion('type', [
|
||||
TextMessageStart,
|
||||
TextMessageContent,
|
||||
TextMessageEnd,
|
||||
ToolCallStart,
|
||||
ToolCallArgs,
|
||||
ToolCallEnd,
|
||||
StateSnapshot,
|
||||
StateDelta,
|
||||
CustomEvent,
|
||||
RunStarted,
|
||||
RunFinished,
|
||||
RunError,
|
||||
]);
|
||||
|
||||
export type AGUIEvent = z.infer<typeof AGUIEvent>;
|
||||
```
|
||||
|
||||
## Define the AG-UI Catalog
|
||||
|
||||
Create a catalog for UI components that agents can render:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const aguiCatalog = createCatalog({
|
||||
components: {
|
||||
Container: {
|
||||
description: 'A container for grouping elements',
|
||||
props: z.object({
|
||||
direction: z.enum(['row', 'column']).optional(),
|
||||
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
||||
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
|
||||
}),
|
||||
},
|
||||
Card: {
|
||||
description: 'A card with optional title',
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
Text: {
|
||||
description: 'Text content',
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
|
||||
}),
|
||||
},
|
||||
Metric: {
|
||||
description: 'Displays a metric value',
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
value: z.union([z.string(), z.number()]),
|
||||
change: z.number().optional(),
|
||||
format: z.enum(['number', 'currency', 'percent']).optional(),
|
||||
}),
|
||||
},
|
||||
Button: {
|
||||
description: 'Interactive button',
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
|
||||
disabled: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
Alert: {
|
||||
description: 'Alert message',
|
||||
props: z.object({
|
||||
message: z.string(),
|
||||
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
|
||||
}),
|
||||
},
|
||||
// Add more components...
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit: {
|
||||
description: 'Submit form data',
|
||||
params: z.object({ formId: z.string() }),
|
||||
},
|
||||
navigate: {
|
||||
description: 'Navigate to a URL',
|
||||
params: z.object({ url: z.string() }),
|
||||
},
|
||||
callback: {
|
||||
description: 'Trigger a callback to the agent',
|
||||
params: z.object({
|
||||
name: z.string(),
|
||||
data: z.record(z.unknown()).optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Build an AG-UI Event Processor
|
||||
|
||||
Process AG-UI events and render UI components:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { AGUIEvent } from './schema';
|
||||
|
||||
interface AGUIState {
|
||||
messages: Array<{
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}>;
|
||||
toolCalls: Map<string, {
|
||||
name: string;
|
||||
args: string;
|
||||
result?: unknown;
|
||||
}>;
|
||||
state: Record<string, unknown>;
|
||||
isRunning: boolean;
|
||||
}
|
||||
|
||||
export function useAGUI() {
|
||||
const [aguiState, setAGUIState] = useState<AGUIState>({
|
||||
messages: [],
|
||||
toolCalls: new Map(),
|
||||
state: {},
|
||||
isRunning: false,
|
||||
});
|
||||
|
||||
const processEvent = useCallback((event: AGUIEvent) => {
|
||||
switch (event.type) {
|
||||
case 'RUN_STARTED':
|
||||
setAGUIState(prev => ({ ...prev, isRunning: true }));
|
||||
break;
|
||||
case 'RUN_FINISHED':
|
||||
setAGUIState(prev => ({ ...prev, isRunning: false }));
|
||||
break;
|
||||
case 'TEXT_MESSAGE_START':
|
||||
setAGUIState(prev => ({
|
||||
...prev,
|
||||
messages: [...prev.messages, {
|
||||
id: event.messageId,
|
||||
role: event.role,
|
||||
content: '',
|
||||
}],
|
||||
}));
|
||||
break;
|
||||
case 'TEXT_MESSAGE_CONTENT':
|
||||
setAGUIState(prev => ({
|
||||
...prev,
|
||||
messages: prev.messages.map(msg =>
|
||||
msg.id === event.messageId
|
||||
? { ...msg, content: msg.content + event.delta }
|
||||
: msg
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'TOOL_CALL_START':
|
||||
setAGUIState(prev => {
|
||||
const toolCalls = new Map(prev.toolCalls);
|
||||
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
|
||||
return { ...prev, toolCalls };
|
||||
});
|
||||
break;
|
||||
case 'TOOL_CALL_ARGS':
|
||||
setAGUIState(prev => {
|
||||
const toolCalls = new Map(prev.toolCalls);
|
||||
const tc = toolCalls.get(event.toolCallId);
|
||||
if (tc) {
|
||||
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
|
||||
}
|
||||
return { ...prev, toolCalls };
|
||||
});
|
||||
break;
|
||||
case 'STATE_SNAPSHOT':
|
||||
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { state: aguiState, processEvent };
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useAGUI } from './use-agui';
|
||||
import { renderToolCallUI } from './renderer';
|
||||
|
||||
export function AGUIChat() {
|
||||
const { state, processEvent } = useAGUI();
|
||||
|
||||
async function startRun(prompt: string) {
|
||||
const response = await fetch('/api/agent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
});
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const lines = decoder.decode(value).split('\n').filter(Boolean);
|
||||
for (const line of lines) {
|
||||
const event = JSON.parse(line);
|
||||
processEvent(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{state.messages.map(msg => (
|
||||
<div key={msg.id} className={`p-3 rounded ${
|
||||
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
|
||||
}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Array.from(state.toolCalls.values()).map((tc, i) => (
|
||||
<div key={i}>{renderToolCallUI(tc)}</div>
|
||||
))}
|
||||
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.querySelector('input');
|
||||
if (input?.value) {
|
||||
startRun(input.value);
|
||||
input.value = '';
|
||||
}
|
||||
}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask the agent..."
|
||||
className="w-full px-4 py-2 border rounded"
|
||||
disabled={state.isRunning}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [OpenAPI integration](/docs/openapi) for rendering forms from API schemas.
|
||||
@@ -1,651 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
export const metadata = { title: "AI SDK Integration" }
|
||||
|
||||
# AI SDK Integration
|
||||
|
||||
Use json-render with the Vercel AI SDK for seamless streaming.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install ai
|
||||
```
|
||||
|
||||
## API Route Setup
|
||||
|
||||
```typescript
|
||||
// app/api/generate/route.ts
|
||||
import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, currentTree } = await req.json();
|
||||
|
||||
// Generate system prompt from catalog
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
// Optionally include current UI state for context
|
||||
const contextPrompt = currentTree
|
||||
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
|
||||
: '';
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: systemPrompt + contextPrompt,
|
||||
prompt,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Hook
|
||||
|
||||
Use `useUIStream` on the client:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useUIStream, Renderer } from '@json-render/react';
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming, error, send } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => send('Create a dashboard with metrics')}
|
||||
disabled={isStreaming}
|
||||
>
|
||||
{isStreaming ? 'Generating...' : 'Generate'}
|
||||
</button>
|
||||
|
||||
{error && <p className="text-red-500">{error.message}</p>}
|
||||
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Prompt Engineering
|
||||
|
||||
The `catalog.prompt()` method creates an optimized system prompt that:
|
||||
|
||||
- Lists all available components and their props
|
||||
- Describes available actions
|
||||
- Specifies the expected JSON output format
|
||||
- Includes examples for better generation
|
||||
|
||||
## Custom System Prompts
|
||||
|
||||
Pass custom rules to tailor AI behavior:
|
||||
|
||||
```typescript
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
'Always use Card components for grouping related content',
|
||||
'Prefer horizontal layouts (Row) for metrics',
|
||||
'Use consistent spacing with padding="md"',
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [progressive streaming](/docs/streaming).
|
||||
@@ -1,112 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "AI SDK Integration | json-render",
|
||||
};
|
||||
|
||||
export default function AiSdkPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">AI SDK Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use json-render with the Vercel AI SDK for seamless streaming.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Installation</h2>
|
||||
<Code lang="bash">npm install ai</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
|
||||
<Code lang="typescript">{`// app/api/generate/route.ts
|
||||
import { streamText } from 'ai';
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const metadata = { title: "@json-render/codegen API" }
|
||||
|
||||
# @json-render/codegen
|
||||
|
||||
Utilities for generating code from UI trees.
|
||||
|
||||
## Tree Traversal
|
||||
|
||||
### traverseSpec
|
||||
|
||||
Walk the UI spec depth-first.
|
||||
|
||||
```typescript
|
||||
function traverseSpec(
|
||||
spec: Spec,
|
||||
visitor: SpecVisitor,
|
||||
startKey?: string
|
||||
): void
|
||||
|
||||
interface SpecVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
}
|
||||
```
|
||||
|
||||
### collectUsedComponents
|
||||
|
||||
Get all unique component types used in a spec.
|
||||
|
||||
```typescript
|
||||
function collectUsedComponents(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const components = collectUsedComponents(spec);
|
||||
// Set { 'Card', 'Metric', 'Chart' }
|
||||
```
|
||||
|
||||
### collectStatePaths
|
||||
|
||||
Get all state paths referenced in props (statePath, bindPath, valuePath, etc.).
|
||||
|
||||
```typescript
|
||||
function collectStatePaths(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const paths = collectStatePaths(spec);
|
||||
// Set { 'analytics/revenue', 'analytics/customers' }
|
||||
```
|
||||
|
||||
### collectActions
|
||||
|
||||
Get all action names used in the spec.
|
||||
|
||||
```typescript
|
||||
function collectActions(spec: Spec): Set<string>
|
||||
|
||||
// Example
|
||||
const actions = collectActions(spec);
|
||||
// Set { 'submit_form', 'refresh_data' }
|
||||
```
|
||||
|
||||
## Serialization
|
||||
|
||||
### serializePropValue
|
||||
|
||||
Serialize a single value to a code string.
|
||||
|
||||
```typescript
|
||||
function serializePropValue(
|
||||
value: unknown,
|
||||
options?: SerializeOptions
|
||||
): { value: string; needsBraces: boolean }
|
||||
|
||||
// Examples
|
||||
serializePropValue("hello")
|
||||
// { value: '"hello"', needsBraces: false }
|
||||
|
||||
serializePropValue(42)
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' })
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }
|
||||
```
|
||||
|
||||
### serializeProps
|
||||
|
||||
Serialize a props object to a JSX attributes string.
|
||||
|
||||
```typescript
|
||||
function serializeProps(
|
||||
props: Record<string, unknown>,
|
||||
options?: SerializeOptions
|
||||
): string
|
||||
|
||||
// Example
|
||||
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
|
||||
// 'title="Dashboard" columns={3} disabled'
|
||||
```
|
||||
|
||||
### escapeString
|
||||
|
||||
Escape a string for use in code.
|
||||
|
||||
```typescript
|
||||
function escapeString(
|
||||
str: string,
|
||||
quotes?: 'single' | 'double'
|
||||
): string
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### GeneratedFile
|
||||
|
||||
```typescript
|
||||
interface GeneratedFile {
|
||||
/** File path relative to project root */
|
||||
path: string;
|
||||
/** File contents */
|
||||
content: string;
|
||||
}
|
||||
```
|
||||
|
||||
### CodeGenerator
|
||||
|
||||
```typescript
|
||||
interface CodeGenerator {
|
||||
/** Generate files from a UI spec */
|
||||
generate(spec: Spec): GeneratedFile[];
|
||||
}
|
||||
```
|
||||
|
||||
### SerializeOptions
|
||||
|
||||
```typescript
|
||||
interface SerializeOptions {
|
||||
/** Quote style for strings */
|
||||
quotes?: 'single' | 'double';
|
||||
/** Indent for objects/arrays */
|
||||
indent?: number;
|
||||
}
|
||||
```
|
||||
@@ -1,130 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
export const metadata = { title: "@json-render/core API" }
|
||||
|
||||
# @json-render/core
|
||||
|
||||
Core types, schemas, and utilities.
|
||||
|
||||
## defineCatalog
|
||||
|
||||
Creates a type-safe catalog definition with schema validation.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
function defineCatalog<T extends ZodType>(
|
||||
s: T,
|
||||
config: CatalogConfig
|
||||
): Catalog
|
||||
|
||||
// Use the React schema for standard UI specs
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {...},
|
||||
actions: {...},
|
||||
});
|
||||
```
|
||||
|
||||
### CatalogConfig
|
||||
|
||||
```typescript
|
||||
interface CatalogConfig {
|
||||
components: Record<string, ComponentDefinition>;
|
||||
actions?: Record<string, ActionDefinition>;
|
||||
functions?: Record<string, FunctionDefinition>;
|
||||
}
|
||||
|
||||
interface ComponentDefinition {
|
||||
props: ZodObject; // Use .nullable() for optional props
|
||||
slots?: string[]; // Named slots (e.g., ["default"])
|
||||
description?: string; // Help AI understand usage
|
||||
}
|
||||
|
||||
interface ActionDefinition {
|
||||
params?: ZodObject;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface FunctionDefinition {
|
||||
description?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Catalog Instance
|
||||
|
||||
The returned catalog provides methods for AI prompt generation, validation, and schema export:
|
||||
|
||||
```typescript
|
||||
interface Catalog {
|
||||
// Data
|
||||
readonly data: CatalogConfig; // The catalog configuration
|
||||
readonly componentNames: string[]; // List of component names
|
||||
readonly actionNames: string[]; // List of action names
|
||||
|
||||
// AI Prompt Generation
|
||||
prompt(options?: PromptOptions): string;
|
||||
|
||||
// Validation
|
||||
validate(spec: unknown): SpecValidationResult;
|
||||
zodSchema(): z.ZodType; // Get the Zod schema for specs
|
||||
|
||||
// Export
|
||||
jsonSchema(): object; // Export as JSON Schema
|
||||
}
|
||||
|
||||
interface PromptOptions {
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
}
|
||||
|
||||
interface SpecValidationResult<T> {
|
||||
success: boolean;
|
||||
data?: T; // Validated spec (if success)
|
||||
error?: z.ZodError; // Validation errors (if failed)
|
||||
}
|
||||
```
|
||||
|
||||
### Catalog Methods
|
||||
|
||||
```typescript
|
||||
// Generate AI system prompt
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: ["Always use Card as root element"],
|
||||
});
|
||||
|
||||
// Validate a spec from AI
|
||||
const result = catalog.validate(aiOutput);
|
||||
if (result.success) {
|
||||
render(result.data);
|
||||
} else {
|
||||
console.error(result.error);
|
||||
}
|
||||
|
||||
// Get Zod schema for custom validation
|
||||
const schema = catalog.zodSchema();
|
||||
const parsed = schema.safeParse(aiOutput);
|
||||
|
||||
// Export as JSON Schema (for structured outputs)
|
||||
const jsonSchema = catalog.jsonSchema();
|
||||
```
|
||||
|
||||
## Schema System
|
||||
|
||||
json-render uses a flexible schema system that defines both the AI output format (spec) and what catalogs must provide. Each renderer package provides its own schema (e.g., @json-render/react exports `schema`).
|
||||
|
||||
### schema
|
||||
|
||||
The schema for flat UI element trees. This is exported from @json-render/react.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
// schema defines:
|
||||
// - Spec shape: { root: string, elements: Record<string, UIElement> }
|
||||
// - Catalog shape: { components: {...}, actions: {...} }
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
slots: ["default"],
|
||||
description: "Container card",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: {
|
||||
params: z.object({ formId: z.string() }),
|
||||
description: "Submit a form",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### defineSchema
|
||||
|
||||
Create custom schemas for different output formats (e.g., page-based, block-based).
|
||||
|
||||
```typescript
|
||||
import { defineSchema } from '@json-render/core';
|
||||
|
||||
const mySchema = defineSchema((s) => ({
|
||||
// What the AI outputs (spec)
|
||||
spec: s.object({
|
||||
title: s.string(),
|
||||
blocks: s.array(s.object({
|
||||
type: s.ref("catalog.blocks"),
|
||||
content: s.any(),
|
||||
})),
|
||||
}),
|
||||
|
||||
// What the catalog must provide
|
||||
catalog: s.object({
|
||||
blocks: s.map({
|
||||
props: s.zod(),
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Schema Builder API
|
||||
|
||||
The schema builder provides these methods:
|
||||
|
||||
```typescript
|
||||
// Primitive types
|
||||
s.string() // String value
|
||||
s.number() // Number value
|
||||
s.boolean() // Boolean value
|
||||
s.any() // Any value
|
||||
|
||||
// Compound types
|
||||
s.array(item) // Array of items
|
||||
s.object({ ... }) // Object with shape
|
||||
s.record(value) // Record/map with value type
|
||||
|
||||
// Catalog references (for type safety)
|
||||
s.ref("catalog.components") // Reference to catalog key (becomes enum)
|
||||
s.propsOf("catalog.components") // Props schema from catalog entry
|
||||
|
||||
// Catalog definitions
|
||||
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
|
||||
s.zod() // Placeholder for user-provided Zod schema
|
||||
|
||||
// Modifiers
|
||||
s.optional() // Mark field as optional
|
||||
```
|
||||
|
||||
## Zod Schemas
|
||||
|
||||
Pre-built Zod schemas for common json-render types:
|
||||
|
||||
### Dynamic Value Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
DynamicValueSchema, // string | number | boolean | null | { path: string }
|
||||
DynamicStringSchema, // string | { path: string }
|
||||
DynamicNumberSchema, // number | { path: string }
|
||||
DynamicBooleanSchema, // boolean | { path: string }
|
||||
} from '@json-render/core';
|
||||
|
||||
// Dynamic values can be literals or data path references
|
||||
type DynamicValue<T> = T | { path: string };
|
||||
|
||||
// Example: a prop that can be a literal or bound to data
|
||||
const schema = z.object({
|
||||
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
|
||||
});
|
||||
```
|
||||
|
||||
### Visibility & Logic Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
VisibilityConditionSchema, // Full visibility condition
|
||||
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
|
||||
} from '@json-render/core';
|
||||
|
||||
// Use in component props that need conditional rendering
|
||||
const schema = z.object({
|
||||
visible: VisibilityConditionSchema.optional(),
|
||||
});
|
||||
```
|
||||
|
||||
### Action Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ActionSchema, // Full action definition
|
||||
ActionConfirmSchema, // Confirmation dialog config
|
||||
ActionOnSuccessSchema, // Success handler config
|
||||
ActionOnErrorSchema, // Error handler config
|
||||
} from '@json-render/core';
|
||||
```
|
||||
|
||||
### Validation Schemas
|
||||
|
||||
```typescript
|
||||
import {
|
||||
ValidationCheckSchema, // Single validation check
|
||||
ValidationConfigSchema, // Full validation config with checks array
|
||||
} from '@json-render/core';
|
||||
```
|
||||
|
||||
## SpecStream
|
||||
|
||||
SpecStream is json-render's streaming format for progressively building specs from JSONL patches.
|
||||
|
||||
### createSpecStreamCompiler
|
||||
|
||||
Create a streaming compiler that incrementally builds a spec:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
|
||||
// Get final result
|
||||
const spec = compiler.getResult();
|
||||
|
||||
// Reset for reuse
|
||||
compiler.reset();
|
||||
```
|
||||
|
||||
### compileSpecStream
|
||||
|
||||
Compile an entire SpecStream string at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":{}}
|
||||
{"op":"add","path":"/root/type","value":"Card"}`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
```
|
||||
|
||||
### Low-Level Utilities
|
||||
|
||||
```typescript
|
||||
import {
|
||||
parseSpecStreamLine,
|
||||
applySpecStreamPatch,
|
||||
} from '@json-render/core';
|
||||
|
||||
// Parse a single line
|
||||
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||
|
||||
// Apply patch to object (mutates in place)
|
||||
const obj = {};
|
||||
applySpecStreamPatch(obj, patch);
|
||||
```
|
||||
|
||||
### SpecStream Types
|
||||
|
||||
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
|
||||
|
||||
```typescript
|
||||
interface SpecStreamLine {
|
||||
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||
path: string;
|
||||
value?: unknown; // Required for add, replace, test
|
||||
from?: string; // Required for move, copy
|
||||
}
|
||||
|
||||
interface SpecStreamCompiler<T> {
|
||||
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
||||
getResult(): T;
|
||||
getPatches(): SpecStreamLine[];
|
||||
reset(): void;
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### Path Utilities
|
||||
|
||||
```typescript
|
||||
import { getByPath, setByPath } from '@json-render/core';
|
||||
|
||||
// Get value by JSON Pointer path
|
||||
const value = getByPath(data, '/user/name'); // "Alice"
|
||||
|
||||
// Set value by path (mutates object)
|
||||
setByPath(data, '/user/email', 'alice@example.com');
|
||||
```
|
||||
|
||||
### resolveDynamicValue
|
||||
|
||||
```typescript
|
||||
import { resolveDynamicValue } from '@json-render/core';
|
||||
|
||||
// Resolve a dynamic value against data
|
||||
const name = resolveDynamicValue("Hello", data); // "Hello"
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"
|
||||
```
|
||||
|
||||
### findFormValue
|
||||
|
||||
```typescript
|
||||
import { findFormValue } from '@json-render/core';
|
||||
|
||||
// Find form values regardless of path format
|
||||
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
|
||||
const value = findFormValue("name", params, data);
|
||||
```
|
||||
|
||||
## evaluateVisibility
|
||||
|
||||
Evaluates a visibility condition against data and auth state.
|
||||
|
||||
```typescript
|
||||
function evaluateVisibility(
|
||||
condition: VisibilityCondition | undefined,
|
||||
data: Record<string, unknown>,
|
||||
auth?: AuthState
|
||||
): boolean
|
||||
|
||||
type VisibilityCondition =
|
||||
| { path: string }
|
||||
| { auth: 'signedIn' | 'signedOut' | string }
|
||||
| { and: VisibilityCondition[] }
|
||||
| { or: VisibilityCondition[] }
|
||||
| { not: VisibilityCondition }
|
||||
| { eq: [DynamicValue, DynamicValue] }
|
||||
| { gt: [DynamicValue, DynamicValue] }
|
||||
| { gte: [DynamicValue, DynamicValue] }
|
||||
| { lt: [DynamicValue, DynamicValue] }
|
||||
| { lte: [DynamicValue, DynamicValue] };
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### UIElement
|
||||
|
||||
```typescript
|
||||
interface UIElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition;
|
||||
validation?: ValidationSchema;
|
||||
}
|
||||
```
|
||||
|
||||
### Spec (Element Tree)
|
||||
|
||||
```typescript
|
||||
interface Spec {
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>;
|
||||
}
|
||||
```
|
||||
|
||||
Elements are stored as a flat map with string keys. The tree structure is built by following the `children` arrays.
|
||||
|
||||
### Action
|
||||
|
||||
```typescript
|
||||
interface Action {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
confirm?: {
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: 'default' | 'danger';
|
||||
};
|
||||
onSuccess?: { set: Record<string, unknown> };
|
||||
onError?: { set: Record<string, unknown> };
|
||||
}
|
||||
```
|
||||
|
||||
### ValidationSchema
|
||||
|
||||
```typescript
|
||||
interface ValidationSchema {
|
||||
checks: ValidationCheck[];
|
||||
validateOn?: 'change' | 'blur' | 'submit';
|
||||
}
|
||||
|
||||
interface ValidationCheck {
|
||||
fn: string;
|
||||
args?: Record<string, unknown>;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
@@ -1,403 +0,0 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "@json-render/core API | json-render",
|
||||
};
|
||||
|
||||
export default function CoreApiPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Core types, schemas, and utilities.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">defineCatalog</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Creates a type-safe catalog definition with schema validation.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
function defineCatalog<T extends ZodType>(
|
||||
s: T,
|
||||
config: CatalogConfig
|
||||
): Catalog
|
||||
|
||||
// Use the React schema for standard UI specs
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {...},
|
||||
actions: {...},
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
|
||||
<Code lang="typescript">{`interface CatalogConfig {
|
||||
components: Record<string, ComponentDefinition>;
|
||||
actions?: Record<string, ActionDefinition>;
|
||||
functions?: Record<string, FunctionDefinition>;
|
||||
}
|
||||
|
||||
interface ComponentDefinition {
|
||||
props: ZodObject; // Use .nullable() for optional props
|
||||
slots?: string[]; // Named slots (e.g., ["default"])
|
||||
description?: string; // Help AI understand usage
|
||||
}
|
||||
|
||||
interface ActionDefinition {
|
||||
params?: ZodObject;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface FunctionDefinition {
|
||||
description?: string;
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The returned catalog provides methods for AI prompt generation,
|
||||
validation, and schema export:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface Catalog {
|
||||
// Data
|
||||
readonly data: CatalogConfig; // The catalog configuration
|
||||
readonly componentNames: string[]; // List of component names
|
||||
readonly actionNames: string[]; // List of action names
|
||||
|
||||
// AI Prompt Generation
|
||||
prompt(options?: PromptOptions): string;
|
||||
|
||||
// Validation
|
||||
validate(spec: unknown): SpecValidationResult;
|
||||
zodSchema(): z.ZodType; // Get the Zod schema for specs
|
||||
|
||||
// Export
|
||||
jsonSchema(): object; // Export as JSON Schema
|
||||
}
|
||||
|
||||
interface PromptOptions {
|
||||
system?: string; // Custom system message intro
|
||||
customRules?: string[]; // Additional rules to append
|
||||
}
|
||||
|
||||
interface SpecValidationResult<T> {
|
||||
success: boolean;
|
||||
data?: T; // Validated spec (if success)
|
||||
error?: z.ZodError; // Validation errors (if failed)
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
|
||||
<Code lang="typescript">{`// Generate AI system prompt
|
||||
const systemPrompt = catalog.prompt({
|
||||
customRules: ["Always use Card as root element"],
|
||||
});
|
||||
|
||||
// Validate a spec from AI
|
||||
const result = catalog.validate(aiOutput);
|
||||
if (result.success) {
|
||||
render(result.data);
|
||||
} else {
|
||||
console.error(result.error);
|
||||
}
|
||||
|
||||
// Get Zod schema for custom validation
|
||||
const schema = catalog.zodSchema();
|
||||
const parsed = schema.safeParse(aiOutput);
|
||||
|
||||
// Export as JSON Schema (for structured outputs)
|
||||
const jsonSchema = catalog.jsonSchema();`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render uses a flexible schema system that defines both the AI
|
||||
output format (spec) and what catalogs must provide. Each renderer
|
||||
package provides its own schema (e.g., @json-render/react exports{" "}
|
||||
<code className="text-foreground">schema</code>).
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema for flat UI element trees. This is exported from
|
||||
@json-render/react.
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
|
||||
// schema defines:
|
||||
// - Spec shape: { root: string, elements: Record<string, UIElement> }
|
||||
// - Catalog shape: { components: {...}, actions: {...} }
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
slots: ["default"],
|
||||
description: "Container card",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: {
|
||||
params: z.object({ formId: z.string() }),
|
||||
description: "Submit a form",
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create custom schemas for different output formats (e.g., page-based,
|
||||
block-based).
|
||||
</p>
|
||||
<Code lang="typescript">{`import { defineSchema } from '@json-render/core';
|
||||
|
||||
const mySchema = defineSchema((s) => ({
|
||||
// What the AI outputs (spec)
|
||||
spec: s.object({
|
||||
title: s.string(),
|
||||
blocks: s.array(s.object({
|
||||
type: s.ref("catalog.blocks"),
|
||||
content: s.any(),
|
||||
})),
|
||||
}),
|
||||
|
||||
// What the catalog must provide
|
||||
catalog: s.object({
|
||||
blocks: s.map({
|
||||
props: s.zod(),
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}));`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema builder provides these methods:
|
||||
</p>
|
||||
<Code lang="typescript">{`// Primitive types
|
||||
s.string() // String value
|
||||
s.number() // Number value
|
||||
s.boolean() // Boolean value
|
||||
s.any() // Any value
|
||||
|
||||
// Compound types
|
||||
s.array(item) // Array of items
|
||||
s.object({ ... }) // Object with shape
|
||||
s.record(value) // Record/map with value type
|
||||
|
||||
// Catalog references (for type safety)
|
||||
s.ref("catalog.components") // Reference to catalog key (becomes enum)
|
||||
s.propsOf("catalog.components") // Props schema from catalog entry
|
||||
|
||||
// Catalog definitions
|
||||
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
|
||||
s.zod() // Placeholder for user-provided Zod schema
|
||||
|
||||
// Modifiers
|
||||
s.optional() // Mark field as optional`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Pre-built Zod schemas for common json-render types:
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
DynamicValueSchema, // string | number | boolean | null | { path: string }
|
||||
DynamicStringSchema, // string | { path: string }
|
||||
DynamicNumberSchema, // number | { path: string }
|
||||
DynamicBooleanSchema, // boolean | { path: string }
|
||||
} from '@json-render/core';
|
||||
|
||||
// Dynamic values can be literals or data path references
|
||||
type DynamicValue<T> = T | { path: string };
|
||||
|
||||
// Example: a prop that can be a literal or bound to data
|
||||
const schema = z.object({
|
||||
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
Visibility & Logic Schemas
|
||||
</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
VisibilityConditionSchema, // Full visibility condition
|
||||
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
|
||||
} from '@json-render/core';
|
||||
|
||||
// Use in component props that need conditional rendering
|
||||
const schema = z.object({
|
||||
visible: VisibilityConditionSchema.optional(),
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
ActionSchema, // Full action definition
|
||||
ActionConfirmSchema, // Confirmation dialog config
|
||||
ActionOnSuccessSchema, // Success handler config
|
||||
ActionOnErrorSchema, // Error handler config
|
||||
} from '@json-render/core';`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
ValidationCheckSchema, // Single validation check
|
||||
ValidationConfigSchema, // Full validation config with checks array
|
||||
} from '@json-render/core';`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
SpecStream is json-render's streaming format for progressively
|
||||
building specs from JSONL patches.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
createSpecStreamCompiler
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a streaming compiler that incrementally builds a spec:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks
|
||||
const { result, newPatches } = compiler.push(chunk);
|
||||
|
||||
// Get final result
|
||||
const spec = compiler.getResult();
|
||||
|
||||
// Reset for reuse
|
||||
compiler.reset();`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Compile an entire SpecStream string at once:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = \`{"op":"set","path":"/root","value":{}}
|
||||
{"op":"set","path":"/root/type","value":"Card"}\`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
|
||||
<Code lang="typescript">{`import {
|
||||
parseSpecStreamLine,
|
||||
applySpecStreamPatch,
|
||||
} from '@json-render/core';
|
||||
|
||||
// Parse a single line
|
||||
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
|
||||
|
||||
// Apply patch to object (mutates in place)
|
||||
const obj = {};
|
||||
applySpecStreamPatch(obj, patch);`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
|
||||
<Code lang="typescript">{`interface SpecStreamLine {
|
||||
op: 'set' | 'add' | 'replace' | 'remove';
|
||||
path: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
interface SpecStreamCompiler<T> {
|
||||
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
||||
getResult(): T;
|
||||
reset(): void;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
|
||||
<Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
|
||||
|
||||
// Get value by JSON Pointer path
|
||||
const value = getByPath(data, '/user/name'); // "Alice"
|
||||
|
||||
// Set value by path (mutates object)
|
||||
setByPath(data, '/user/email', 'alice@example.com');`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
|
||||
<Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
|
||||
|
||||
// Resolve a dynamic value against data
|
||||
const name = resolveDynamicValue("Hello", data); // "Hello"
|
||||
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
|
||||
<Code lang="typescript">{`import { findFormValue } from '@json-render/core';
|
||||
|
||||
// Find form values regardless of path format
|
||||
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
|
||||
const value = findFormValue("name", params, data);`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Evaluates a visibility condition against data and auth state.
|
||||
</p>
|
||||
<Code lang="typescript">{`function evaluateVisibility(
|
||||
condition: VisibilityCondition | undefined,
|
||||
data: Record<string, unknown>,
|
||||
auth?: AuthState
|
||||
): boolean
|
||||
|
||||
type VisibilityCondition =
|
||||
| { path: string }
|
||||
| { auth: 'signedIn' | 'signedOut' | string }
|
||||
| { and: VisibilityCondition[] }
|
||||
| { or: VisibilityCondition[] }
|
||||
| { not: VisibilityCondition }
|
||||
| { eq: [DynamicValue, DynamicValue] }
|
||||
| { gt: [DynamicValue, DynamicValue] }
|
||||
| { gte: [DynamicValue, DynamicValue] }
|
||||
| { lt: [DynamicValue, DynamicValue] }
|
||||
| { lte: [DynamicValue, DynamicValue] };`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
|
||||
<Code lang="typescript">{`interface UIElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children?: string[]; // Keys of child elements
|
||||
visible?: VisibilityCondition;
|
||||
validation?: ValidationSchema;
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
|
||||
<Code lang="typescript">{`interface Spec {
|
||||
root: string | null; // Key of root element
|
||||
elements: Record<string, UIElement>;
|
||||
}`}</Code>
|
||||
<p className="text-sm text-muted-foreground mt-2 mb-4">
|
||||
Elements are stored as a flat map with string keys. The tree structure
|
||||
is built by following the{" "}
|
||||
<code className="text-foreground">children</code> arrays.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
|
||||
<Code lang="typescript">{`interface Action {
|
||||
name: string;
|
||||
params?: Record<string, unknown>;
|
||||
confirm?: {
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: 'default' | 'danger';
|
||||
};
|
||||
onSuccess?: { set: Record<string, unknown> };
|
||||
onError?: { set: Record<string, unknown> };
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
|
||||
<Code lang="typescript">{`interface ValidationSchema {
|
||||
checks: ValidationCheck[];
|
||||
validateOn?: 'change' | 'blur' | 'submit';
|
||||
}
|
||||
|
||||
interface ValidationCheck {
|
||||
fn: string;
|
||||
args?: Record<string, unknown>;
|
||||
message: string;
|
||||
}`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
export const metadata = { title: "@json-render/react-native API" }
|
||||
|
||||
# @json-render/react-native
|
||||
|
||||
React Native renderer with standard components, providers, and hooks.
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Container` | `padding`, `background`, `borderRadius`, `borderColor`, `flex` | Basic wrapper with styling |
|
||||
| `Row` | `gap`, `align`, `justify`, `flex`, `wrap` | Horizontal flex layout |
|
||||
| `Column` | `gap`, `align`, `justify`, `flex` | Vertical flex layout |
|
||||
| `ScrollContainer` | `direction` | Scrollable area (vertical or horizontal) |
|
||||
| `SafeArea` | `edges` | Safe area insets for notch/home indicator |
|
||||
| `Pressable` | `action`, `actionParams` | Touchable wrapper that triggers actions |
|
||||
| `Spacer` | `size`, `flex` | Fixed or flexible spacing |
|
||||
| `Divider` | `color`, `thickness` | Thin line separator |
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Heading` | `text`, `level`, `align`, `color` | Heading text (levels 1-6) |
|
||||
| `Paragraph` | `text`, `align`, `color` | Body text |
|
||||
| `Label` | `text`, `color`, `bold` | Small label text |
|
||||
| `Image` | `uri`, `width`, `height`, `resizeMode`, `borderRadius` | Image display |
|
||||
| `Avatar` | `uri`, `size`, `fallback` | Circular avatar |
|
||||
| `Badge` | `label`, `color`, `textColor` | Status badge |
|
||||
| `Chip` | `label`, `selected`, `color` | Tag/chip |
|
||||
|
||||
### Input
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
|
||||
| `TextInput` | `placeholder`, `statePath`, `secure`, `keyboardType`, `multiline` | Text input field |
|
||||
| `Switch` | `statePath`, `label` | Toggle switch |
|
||||
| `Checkbox` | `statePath`, `label` | Checkbox with label |
|
||||
| `Slider` | `statePath`, `min`, `max`, `step` | Range slider |
|
||||
| `SearchBar` | `placeholder`, `statePath` | Search input |
|
||||
|
||||
### Feedback
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Spinner` | `size`, `color` | Loading indicator |
|
||||
| `ProgressBar` | `progress`, `color`, `trackColor` | Progress indicator |
|
||||
|
||||
### Composite
|
||||
|
||||
| Component | Props | Description |
|
||||
|-----------|-------|-------------|
|
||||
| `Card` | `title`, `subtitle`, `padding` | Card container |
|
||||
| `ListItem` | `title`, `subtitle`, `leading`, `trailing`, `action`, `actionParams` | List row |
|
||||
| `Modal` | `visible`, `title` | Bottom sheet modal |
|
||||
|
||||
## Providers
|
||||
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
<ActionProvider handlers={Record<string, ActionHandler>}>
|
||||
{children}
|
||||
</ActionProvider>
|
||||
```
|
||||
|
||||
### VisibilityProvider
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
```
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
<ValidationProvider>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
```
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry. Standard components are built-in; only register custom components.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry, type Components } from '@json-render/react-native';
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Icon: ({ props }) => <Ionicons name={props.name} size={props.size ?? 24} />,
|
||||
} as Components<typeof catalog>,
|
||||
});
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### useUIStream
|
||||
|
||||
```typescript
|
||||
const {
|
||||
spec, // Spec | null - current UI state
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (prompt: string) => Promise<void>
|
||||
clear, // () => void - reset spec and error
|
||||
} = useUIStream({
|
||||
api: string,
|
||||
onComplete?: (spec: Spec) => void,
|
||||
onError?: (error: Error) => void,
|
||||
});
|
||||
```
|
||||
|
||||
### useStateStore
|
||||
|
||||
```typescript
|
||||
const { state, get, set, update } = useStateStore();
|
||||
```
|
||||
|
||||
### useStateValue
|
||||
|
||||
```typescript
|
||||
const value = useStateValue(path: string);
|
||||
```
|
||||
|
||||
### useStateBinding
|
||||
|
||||
```typescript
|
||||
const [value, setValue] = useStateBinding(path: string);
|
||||
```
|
||||
|
||||
### useActions
|
||||
|
||||
```typescript
|
||||
const { execute } = useActions();
|
||||
```
|
||||
|
||||
### useIsVisible
|
||||
|
||||
```typescript
|
||||
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||
```
|
||||
|
||||
## Catalog Exports
|
||||
|
||||
```typescript
|
||||
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
|
||||
import { schema } from "@json-render/react-native/schema";
|
||||
```
|
||||
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `standardComponentDefinitions` | Catalog definitions for all 25+ standard components |
|
||||
| `standardActionDefinitions` | Catalog definitions for standard actions (setState, navigate) |
|
||||
| `schema` | React Native element tree schema |
|
||||
@@ -0,0 +1,169 @@
|
||||
export const metadata = { title: "@json-render/react API" }
|
||||
|
||||
# @json-render/react
|
||||
|
||||
React components, providers, and hooks.
|
||||
|
||||
## Providers
|
||||
|
||||
### StateProvider
|
||||
|
||||
```tsx
|
||||
<StateProvider initialState={object}>
|
||||
{children}
|
||||
</StateProvider>
|
||||
```
|
||||
|
||||
### ActionProvider
|
||||
|
||||
```tsx
|
||||
<ActionProvider handlers={Record<string, ActionHandler>}>
|
||||
{children}
|
||||
</ActionProvider>
|
||||
|
||||
type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||
```
|
||||
|
||||
### VisibilityProvider
|
||||
|
||||
```tsx
|
||||
<VisibilityProvider auth={AuthState}>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
|
||||
interface AuthState {
|
||||
isSignedIn: boolean;
|
||||
roles?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### ValidationProvider
|
||||
|
||||
```tsx
|
||||
<ValidationProvider functions={Record<string, ValidatorFn>}>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
|
||||
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||
```
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, 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, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>
|
||||
{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)
|
||||
emit?: (event: string) => void; // Emit a named event
|
||||
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[]);
|
||||
```
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "@json-render/react API | json-render",
|
||||
};
|
||||
|
||||
export default function ReactApiPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">@json-render/react</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
React components, providers, and hooks.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Providers</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">DataProvider</h3>
|
||||
<Code lang="tsx">{`<DataProvider initialData={object}>
|
||||
{children}
|
||||
</DataProvider>`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ActionProvider</h3>
|
||||
<Code lang="tsx">{`<ActionProvider handlers={Record<string, ActionHandler>}>
|
||||
{children}
|
||||
</ActionProvider>
|
||||
|
||||
type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">VisibilityProvider</h3>
|
||||
<Code lang="tsx">{`<VisibilityProvider auth={AuthState}>
|
||||
{children}
|
||||
</VisibilityProvider>
|
||||
|
||||
interface AuthState {
|
||||
isSignedIn: boolean;
|
||||
roles?: string[];
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationProvider</h3>
|
||||
<Code lang="tsx">{`<ValidationProvider functions={Record<string, ValidatorFn>}>
|
||||
{children}
|
||||
</ValidationProvider>
|
||||
|
||||
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">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 - current UI state
|
||||
isStreaming, // boolean - true while streaming
|
||||
error, // Error | null
|
||||
send, // (prompt: string) => void
|
||||
abort, // () => void
|
||||
} = useUIStream({
|
||||
api: string, // API endpoint URL
|
||||
onChunk?: (chunk: string) => void, // Called for each chunk
|
||||
onFinish?: (spec: Spec) => void, // Called when streaming completes
|
||||
});`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
export const metadata = { title: "@json-render/remotion API" }
|
||||
|
||||
# @json-render/remotion
|
||||
|
||||
Remotion video renderer. Turn JSON timeline specs into video compositions.
|
||||
|
||||
## schema
|
||||
|
||||
The timeline schema for video specs. Use with `defineCatalog` from core.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema, standardComponentDefinitions } from '@json-render/remotion';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
transitions: standardTransitionDefinitions,
|
||||
effects: standardEffectDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
## Renderer
|
||||
|
||||
The main composition component that renders timeline specs. Use with Remotion's Player or in a Remotion project.
|
||||
|
||||
```tsx
|
||||
import { Player } from '@remotion/player';
|
||||
import { Renderer } from '@json-render/remotion';
|
||||
|
||||
function VideoPlayer({ spec }) {
|
||||
return (
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec }}
|
||||
durationInFrames={spec.composition.durationInFrames}
|
||||
fps={spec.composition.fps}
|
||||
compositionWidth={spec.composition.width}
|
||||
compositionHeight={spec.composition.height}
|
||||
controls
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Components
|
||||
|
||||
Pass custom components to the Renderer:
|
||||
|
||||
```tsx
|
||||
import { Renderer, standardComponents } from '@json-render/remotion';
|
||||
|
||||
const customComponents = {
|
||||
...standardComponents,
|
||||
MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
|
||||
};
|
||||
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec, components: customComponents }}
|
||||
// ...
|
||||
/>
|
||||
```
|
||||
|
||||
## Standard Components
|
||||
|
||||
Pre-built video components included in the package:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
TitleCard, // Full-screen title with subtitle
|
||||
ImageSlide, // Full-screen image display
|
||||
SplitScreen, // Two-column layout
|
||||
QuoteCard, // Quote with attribution
|
||||
StatCard, // Large statistic display
|
||||
LowerThird, // Name/title overlay
|
||||
TextOverlay, // Centered text overlay
|
||||
TypingText, // Terminal typing animation
|
||||
LogoBug, // Corner logo watermark
|
||||
VideoClip, // Video playback
|
||||
} from '@json-render/remotion';
|
||||
```
|
||||
|
||||
### TitleCard Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backgroundColor?: string; // default: "#1a1a1a"
|
||||
textColor?: string; // default: "#ffffff"
|
||||
}
|
||||
```
|
||||
|
||||
### TypingText Props
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
charsPerSecond?: number; // default: 15
|
||||
showCursor?: boolean; // default: true
|
||||
cursorChar?: string; // default: "|"
|
||||
fontFamily?: string; // default: "monospace"
|
||||
fontSize?: number; // default: 48
|
||||
textColor?: string; // default: "#00ff00"
|
||||
backgroundColor?: string; // default: "#1e1e1e"
|
||||
}
|
||||
```
|
||||
|
||||
## Catalog Definitions
|
||||
|
||||
Pre-built definitions for creating catalogs:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
standardComponentDefinitions, // All standard component definitions
|
||||
standardTransitionDefinitions, // fade, slideLeft, slideRight, etc.
|
||||
standardEffectDefinitions, // kenBurns, pulseGlow, colorShift
|
||||
} from '@json-render/remotion';
|
||||
|
||||
// Use in your catalog
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...standardComponentDefinitions,
|
||||
// Add custom components
|
||||
},
|
||||
transitions: standardTransitionDefinitions,
|
||||
effects: standardEffectDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
## Hooks & Utilities
|
||||
|
||||
### useTransition
|
||||
|
||||
Calculate transition styles for a clip based on current frame:
|
||||
|
||||
```typescript
|
||||
import { useTransition } from '@json-render/remotion';
|
||||
import { useCurrentFrame } from 'remotion';
|
||||
|
||||
function MyComponent({ clip }) {
|
||||
const frame = useCurrentFrame();
|
||||
const transition = useTransition(clip, frame);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
opacity: transition.opacity,
|
||||
transform: transition.transform,
|
||||
}}>
|
||||
Content
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ClipWrapper
|
||||
|
||||
Automatically apply transitions to clip content:
|
||||
|
||||
```tsx
|
||||
import { ClipWrapper } from '@json-render/remotion';
|
||||
|
||||
function MyClip({ clip }) {
|
||||
return (
|
||||
<ClipWrapper clip={clip}>
|
||||
<div>My content with automatic transitions</div>
|
||||
</ClipWrapper>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
### TimelineSpec
|
||||
|
||||
```typescript
|
||||
interface TimelineSpec {
|
||||
composition: {
|
||||
id: string;
|
||||
fps: number;
|
||||
width: number;
|
||||
height: number;
|
||||
durationInFrames: number;
|
||||
};
|
||||
tracks: Track[];
|
||||
clips: Clip[];
|
||||
audio: {
|
||||
tracks: AudioTrack[];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Clip
|
||||
|
||||
```typescript
|
||||
interface Clip {
|
||||
id: string;
|
||||
trackId: string;
|
||||
component: string;
|
||||
props: Record<string, unknown>;
|
||||
from: number;
|
||||
durationInFrames: number;
|
||||
transitionIn?: {
|
||||
type: string;
|
||||
durationInFrames: number;
|
||||
};
|
||||
transitionOut?: {
|
||||
type: string;
|
||||
durationInFrames: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### TransitionStyles
|
||||
|
||||
```typescript
|
||||
interface TransitionStyles {
|
||||
opacity: number;
|
||||
transform: string;
|
||||
}
|
||||
```
|
||||
|
||||
### ComponentRegistry
|
||||
|
||||
```typescript
|
||||
type ClipComponent = React.ComponentType<{ clip: Clip }>;
|
||||
type ComponentRegistry = Record<string, ClipComponent>;
|
||||
```
|
||||
|
||||
## Transitions
|
||||
|
||||
Available transition types:
|
||||
|
||||
- `fade` - Opacity fade in/out
|
||||
- `slideLeft` - Slide from right
|
||||
- `slideRight` - Slide from left
|
||||
- `slideUp` - Slide from bottom
|
||||
- `slideDown` - Slide from top
|
||||
- `zoom` - Scale zoom in/out
|
||||
- `wipe` - Horizontal wipe
|
||||
@@ -1,240 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
export const metadata = { title: "Catalog" }
|
||||
|
||||
# Catalog
|
||||
|
||||
The catalog defines what AI can generate. It's your guardrail.
|
||||
|
||||
## What is a Catalog?
|
||||
|
||||
A catalog is a schema that defines:
|
||||
|
||||
- **Components** — UI elements AI can create (with props and optional slots)
|
||||
- **Actions** — Operations AI can trigger
|
||||
- **Functions** — Custom validation or transformation functions
|
||||
|
||||
## Creating a Catalog
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
// Define each component with its props schema
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
padding: z.enum(['sm', 'md', 'lg']).nullable(),
|
||||
}),
|
||||
slots: ["default"], // Can contain other components
|
||||
description: "Container card for grouping content",
|
||||
},
|
||||
|
||||
Metric: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
valuePath: z.string(), // JSON Pointer to data
|
||||
format: z.enum(['currency', 'percent', 'number']),
|
||||
}),
|
||||
description: "Display a single metric value",
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: 'Submit a form',
|
||||
},
|
||||
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
description: 'Export data in various formats',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Component Definition
|
||||
|
||||
Each component in the catalog has:
|
||||
|
||||
```typescript
|
||||
{
|
||||
props: z.object({...}), // Zod schema for props (use .nullable() for optional)
|
||||
slots?: string[], // Named slots for children (e.g., ["default"])
|
||||
description?: string, // Help AI understand when to use it
|
||||
}
|
||||
```
|
||||
|
||||
Use `slots: ["default"]` for components that can contain children. The slot name corresponds to where child elements are rendered.
|
||||
|
||||
## Generating AI Prompts
|
||||
|
||||
Use the `catalog.prompt()` method to generate a system prompt for AI:
|
||||
|
||||
```typescript
|
||||
// Generate a system prompt from your catalog
|
||||
const systemPrompt = catalog.prompt();
|
||||
|
||||
// Or with custom rules for the AI
|
||||
const customPrompt = catalog.prompt({
|
||||
customRules: [
|
||||
"Always use Card as the root element for forms",
|
||||
"Group related inputs in a Stack with direction=vertical",
|
||||
],
|
||||
});
|
||||
|
||||
// Pass this to your AI model as the system prompt
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn how to [register components](/docs/registry) in your registry.
|
||||
@@ -1,126 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Catalog | json-render",
|
||||
};
|
||||
|
||||
export default function CatalogPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Catalog</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
The catalog defines what AI can generate. It's your guardrail.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A catalog is a schema that defines:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Components</strong> — UI elements
|
||||
AI can create (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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
export const metadata = { title: "Changelog" }
|
||||
|
||||
# Changelog
|
||||
|
||||
Notable changes and updates to json-render.
|
||||
|
||||
## v0.5.0
|
||||
|
||||
February 2026
|
||||
|
||||
### New: @json-render/react-native
|
||||
|
||||
Full React Native renderer with 25+ standard components, data binding, visibility, actions, and dynamic props. Build AI-generated native mobile UIs with the same catalog-driven approach as web.
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react-native/schema";
|
||||
import {
|
||||
standardComponentDefinitions,
|
||||
standardActionDefinitions,
|
||||
} from "@json-render/react-native/catalog";
|
||||
import { defineRegistry, Renderer } from "@json-render/react-native";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { ...standardComponentDefinitions },
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components: {} });
|
||||
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
Includes standard components for layout (Container, Row, Column, ScrollContainer, SafeArea, Pressable, Spacer, Divider), content (Heading, Paragraph, Label, Image, Avatar, Badge, Chip), input (Button, TextInput, Switch, Checkbox, Slider, SearchBar), feedback (Spinner, ProgressBar), and composite (Card, ListItem, Modal).
|
||||
|
||||
### New: Event System
|
||||
|
||||
Components now use `emit` to fire named events instead of directly dispatching actions. The element's `on` field maps events to action bindings, decoupling component logic from action handling.
|
||||
|
||||
```tsx
|
||||
// Component emits a named event
|
||||
Button: ({ props, emit }) => (
|
||||
<button onClick={() => emit?.("press")}>{props.label}</button>
|
||||
),
|
||||
|
||||
// Element spec maps events to actions
|
||||
{
|
||||
"type": "Button",
|
||||
"props": { "label": "Submit" },
|
||||
"on": { "press": { "action": "submit", "params": { "formId": "main" } } }
|
||||
}
|
||||
```
|
||||
|
||||
### New: Repeat/List Rendering
|
||||
|
||||
Elements can now iterate over state arrays using the `repeat` field. Child elements use `$item` and `$index` tokens in `$path` expressions to reference the current item.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Column",
|
||||
"repeat": { "path": "/posts", "key": "id" },
|
||||
"children": ["post-card"]
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Card",
|
||||
"props": { "title": { "$path": "$item/title" } }
|
||||
}
|
||||
```
|
||||
|
||||
### New: User Prompt Builder
|
||||
|
||||
Build structured user prompts with optional spec refinement and state context:
|
||||
|
||||
```typescript
|
||||
import { buildUserPrompt } from "@json-render/core";
|
||||
|
||||
// Fresh generation
|
||||
buildUserPrompt({ prompt: "create a todo app" });
|
||||
|
||||
// Refinement (patch-only mode)
|
||||
buildUserPrompt({ prompt: "add a toggle", currentSpec: spec });
|
||||
|
||||
// With runtime state
|
||||
buildUserPrompt({ prompt: "show data", state: { todos: [] } });
|
||||
```
|
||||
|
||||
### New: Spec Validation
|
||||
|
||||
Validate spec structure and auto-fix common issues:
|
||||
|
||||
```typescript
|
||||
import { validateSpec, autoFixSpec } from "@json-render/core";
|
||||
|
||||
const { valid, issues } = validateSpec(spec, catalog);
|
||||
const fixed = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
### Improved: State Management
|
||||
|
||||
`DataProvider` has been renamed to `StateProvider` with a clearer API. State is now a first-class part of specs. Elements can bind to state via `$path` expressions, and the built-in `setState` action updates state directly.
|
||||
|
||||
### Improved: AI Prompts
|
||||
|
||||
Schema prompts now include streaming best practices, repeat/list examples, and state patching guidance. Schemas can also define `defaultRules` that are always included in generated prompts.
|
||||
|
||||
### Improved: Documentation
|
||||
|
||||
- All documentation pages migrated to MDX
|
||||
- AI-powered documentation chat
|
||||
- Dynamic Open Graph images for all docs pages
|
||||
- Improved playground
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- `DataProvider` renamed to `StateProvider`
|
||||
- `useData` renamed to `useStateStore`, `useDataValue` to `useStateValue`, `useDataBinding` to `useStateBinding`
|
||||
- `onAction` renamed to `emit` in component context
|
||||
- `DataModel` type renamed to `StateModel`
|
||||
- `Action` type renamed to `ActionBinding` (old name still available but deprecated)
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
@@ -1,207 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export const metadata = { title: "Code Export" }
|
||||
|
||||
# Code Export
|
||||
|
||||
Export generated UI as standalone code for your framework.
|
||||
|
||||
## Overview
|
||||
|
||||
While json-render is designed for dynamic rendering, you can export generated UI as static code. The code generation is intentionally project-specific so you have full control over:
|
||||
|
||||
- Component templates (standalone, no json-render dependencies)
|
||||
- Package.json and project structure
|
||||
- Framework-specific patterns (Next.js, Remix, etc.)
|
||||
- How data is passed to components
|
||||
|
||||
## Architecture
|
||||
|
||||
Code export is split into two parts:
|
||||
|
||||
### 1. @json-render/codegen (utilities)
|
||||
|
||||
Framework-agnostic utilities for building code generators:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
traverseSpec, // Walk the UI spec
|
||||
collectUsedComponents, // Get all component types used
|
||||
collectStatePaths, // Get all data binding paths
|
||||
collectActions, // Get all action names
|
||||
serializeProps, // Convert props to JSX string
|
||||
} from '@json-render/codegen';
|
||||
```
|
||||
|
||||
### 2. Your Project (generator)
|
||||
|
||||
Custom code generator specific to your project and framework:
|
||||
|
||||
```typescript
|
||||
// lib/codegen/generator.ts
|
||||
import { collectUsedComponents, serializeProps } from '@json-render/codegen';
|
||||
|
||||
export function generateNextJSProject(spec: Spec): GeneratedFile[] {
|
||||
const components = collectUsedComponents(spec);
|
||||
|
||||
return [
|
||||
{ path: 'package.json', content: '...' },
|
||||
{ path: 'app/page.tsx', content: '...' },
|
||||
// ... component files
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Example: Next.js Export
|
||||
|
||||
See the dashboard example for a complete implementation that exports:
|
||||
|
||||
- `package.json` - Dependencies and scripts
|
||||
- `tsconfig.json` - TypeScript config
|
||||
- `next.config.js` - Next.js config
|
||||
- `app/layout.tsx` - Root layout
|
||||
- `app/globals.css` - Global styles
|
||||
- `app/page.tsx` - Generated page with data
|
||||
- `components/ui/*.tsx` - Standalone components
|
||||
|
||||
## Standalone Components
|
||||
|
||||
The exported components are standalone with no json-render dependencies. They receive data as props instead of using hooks:
|
||||
|
||||
```tsx
|
||||
// Generated component (standalone)
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
valuePath: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function Metric({ label, valuePath, data }: MetricProps) {
|
||||
const value = data ? getByPath(data, valuePath) : undefined;
|
||||
return (
|
||||
<div>
|
||||
<span>{label}</span>
|
||||
<span>{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the Utilities
|
||||
|
||||
### traverseSpec
|
||||
|
||||
```typescript
|
||||
import { traverseSpec } from '@json-render/codegen';
|
||||
|
||||
traverseSpec(spec, (element, depth, parent) => {
|
||||
console.log(' '.repeat(depth * 2) + element.type);
|
||||
});
|
||||
```
|
||||
|
||||
### collectUsedComponents
|
||||
|
||||
```typescript
|
||||
import { collectUsedComponents } from '@json-render/codegen';
|
||||
|
||||
const components = collectUsedComponents(spec);
|
||||
// Set { 'Card', 'Metric', 'Chart', 'Table' }
|
||||
|
||||
// Generate only the needed component files
|
||||
for (const component of components) {
|
||||
files.push({
|
||||
path: `components/ui/${component.toLowerCase()}.tsx`,
|
||||
content: componentTemplates[component],
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### serializeProps
|
||||
|
||||
```typescript
|
||||
import { serializeProps } from '@json-render/codegen';
|
||||
|
||||
const propsStr = serializeProps({
|
||||
title: 'Dashboard',
|
||||
columns: 3,
|
||||
disabled: true,
|
||||
});
|
||||
// 'title="Dashboard" columns={3} disabled'
|
||||
```
|
||||
|
||||
## Try It
|
||||
|
||||
Run the dashboard example and click "Export Project" to see code generation in action:
|
||||
|
||||
```bash
|
||||
cd examples/dashboard
|
||||
pnpm dev
|
||||
# Open http://localhost:3001
|
||||
# Generate a widget, then click "Export Project"
|
||||
```
|
||||
@@ -1,170 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
+67
-94
@@ -1,37 +1,19 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
export const metadata = { title: "Custom Schema & Renderer" }
|
||||
|
||||
export const metadata = {
|
||||
title: "Custom Schema & Renderer | json-render",
|
||||
};
|
||||
# Custom Schema & Renderer
|
||||
|
||||
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>
|
||||
Build your own schema and renderer with `@json-render/core`.
|
||||
|
||||
<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>
|
||||
## Overview
|
||||
|
||||
<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">{`{
|
||||
`@json-render/core` is schema-agnostic. While `@json-render/react` provides a ready-to-use schema and renderer, you can create your own to match any JSON structure - whether it's a domain-specific format, an existing protocol, or something entirely custom.
|
||||
|
||||
## 1. Define Your Schema
|
||||
|
||||
Start by defining the JSON structure your system will use. Here's an example of a simple dashboard schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"layout": "grid",
|
||||
"columns": 2,
|
||||
"widgets": [
|
||||
@@ -54,15 +36,15 @@ export default function CustomSchemaPage() {
|
||||
"dataKey": "orders"
|
||||
}
|
||||
]
|
||||
}`}</Code>
|
||||
}
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
2. Create the Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define a catalog that describes your components and validates props:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
## 2. Create the Catalog
|
||||
|
||||
Define a catalog that describes your components and validates props:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const dashboardCatalog = createCatalog({
|
||||
@@ -102,15 +84,15 @@ export const dashboardCatalog = createCatalog({
|
||||
}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
});
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
3. Define the Root Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a schema for the overall document structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
## 3. Define the Root Schema
|
||||
|
||||
Create a schema for the overall document structure:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
const WidgetSchema = z.object({
|
||||
type: z.string(),
|
||||
@@ -125,15 +107,15 @@ export const DashboardSchema = z.object({
|
||||
});
|
||||
|
||||
export type Dashboard = z.infer<typeof DashboardSchema>;
|
||||
export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
|
||||
export type Widget = z.infer<typeof WidgetSchema>;
|
||||
```
|
||||
|
||||
<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';
|
||||
## 4. Build the Renderer
|
||||
|
||||
Create a renderer that maps your schema to React components:
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { dashboardCatalog } from './catalog';
|
||||
import type { Dashboard, Widget } from './schema';
|
||||
|
||||
@@ -144,7 +126,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>
|
||||
)}
|
||||
@@ -204,7 +186,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];
|
||||
@@ -214,7 +196,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;
|
||||
}
|
||||
|
||||
@@ -231,15 +213,15 @@ export function DashboardRenderer({
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
}
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
5. Generate LLM Prompts
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use the catalog to generate system prompts for AI:
|
||||
</p>
|
||||
<Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
|
||||
## 5. Generate LLM Prompts
|
||||
|
||||
Use the catalog to generate system prompts for AI:
|
||||
|
||||
```typescript
|
||||
const systemPrompt = dashboardCatalog.prompt({
|
||||
customRules: [
|
||||
'Use metric widgets for single KPI values',
|
||||
'Use chart widgets for time-series data',
|
||||
@@ -253,13 +235,15 @@ const response = await generateText({
|
||||
model: 'gpt-4',
|
||||
system: systemPrompt,
|
||||
prompt: 'Create a sales dashboard with revenue, orders, and a chart',
|
||||
});`}</Code>
|
||||
});
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Validate incoming specs against your schema:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { validate } from '@json-render/core';
|
||||
## 6. Validate Specs
|
||||
|
||||
Validate incoming specs against your schema:
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
|
||||
function validateDashboard(spec: unknown) {
|
||||
// Validate root structure
|
||||
@@ -276,15 +260,18 @@ 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>
|
||||
}
|
||||
```
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
## Usage Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { DashboardRenderer } from './renderer';
|
||||
@@ -313,23 +300,9 @@ 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.
|
||||
@@ -0,0 +1,124 @@
|
||||
export const metadata = { title: "Data Binding" }
|
||||
|
||||
# Data Binding
|
||||
|
||||
Connect UI components to your application data using JSON Pointer paths.
|
||||
|
||||
## JSON Pointer Paths
|
||||
|
||||
json-render uses JSON Pointer (RFC 6901) for data paths:
|
||||
|
||||
```json
|
||||
// Given this data:
|
||||
{
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": 125000,
|
||||
"growth": 0.15
|
||||
}
|
||||
}
|
||||
|
||||
// These paths access:
|
||||
"/user/name" -> "Alice"
|
||||
"/metrics/revenue" -> 125000
|
||||
"/metrics/growth" -> 0.15
|
||||
```
|
||||
|
||||
## StateProvider
|
||||
|
||||
Wrap your app with StateProvider to enable data binding:
|
||||
|
||||
```tsx
|
||||
import { StateProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const initialState = {
|
||||
user: { name: 'Alice' },
|
||||
form: { email: '', message: '' },
|
||||
};
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
{/* Your UI */}
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Reading Data
|
||||
|
||||
Use `useStateValue` for read-only access:
|
||||
|
||||
```tsx
|
||||
import { useStateValue } from '@json-render/react';
|
||||
|
||||
function UserGreeting() {
|
||||
const name = useStateValue('/user/name');
|
||||
return <h1>Hello, {name}!</h1>;
|
||||
}
|
||||
```
|
||||
|
||||
## Two-Way Binding
|
||||
|
||||
Use `useStateBinding` for read-write access:
|
||||
|
||||
```tsx
|
||||
import { useStateBinding } from '@json-render/react';
|
||||
|
||||
function EmailInput() {
|
||||
const [email, setEmail] = useStateBinding('/form/email');
|
||||
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
value={email || ''}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using the State Context
|
||||
|
||||
Access the full state context for advanced use cases:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
|
||||
function StateDebugger() {
|
||||
const { data, setState, getValue, setValue } = useStateStore();
|
||||
|
||||
// Read any path
|
||||
const revenue = getValue('/metrics/revenue');
|
||||
|
||||
// Write any path
|
||||
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
||||
|
||||
// Replace all state
|
||||
const resetState = () => setState({ user: {}, form: {} });
|
||||
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}
|
||||
```
|
||||
|
||||
## In JSON UI Trees
|
||||
|
||||
AI can reference data paths in component props:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [actions](/docs/actions) for user interactions.
|
||||
@@ -1,133 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Data Binding | json-render",
|
||||
};
|
||||
|
||||
export default function DataBindingPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Data Binding</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Connect UI components to your application data using JSON Pointer paths.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">JSON Pointer Paths</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render uses JSON Pointer (RFC 6901) for data paths:
|
||||
</p>
|
||||
<Code lang="json">{`// Given this data:
|
||||
{
|
||||
"user": {
|
||||
"name": "Alice",
|
||||
"email": "alice@example.com"
|
||||
},
|
||||
"metrics": {
|
||||
"revenue": 125000,
|
||||
"growth": 0.15
|
||||
}
|
||||
}
|
||||
|
||||
// These paths access:
|
||||
"/user/name" -> "Alice"
|
||||
"/metrics/revenue" -> 125000
|
||||
"/metrics/growth" -> 0.15`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">DataProvider</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Wrap your app with DataProvider to enable data binding:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { DataProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const initialData = {
|
||||
user: { name: 'Alice' },
|
||||
form: { email: '', message: '' },
|
||||
};
|
||||
|
||||
return (
|
||||
<DataProvider initialData={initialData}>
|
||||
{/* Your UI */}
|
||||
</DataProvider>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Reading Data</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use <code className="text-foreground">useDataValue</code> for read-only
|
||||
access:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useDataValue } from '@json-render/react';
|
||||
|
||||
function UserGreeting() {
|
||||
const name = useDataValue('/user/name');
|
||||
return <h1>Hello, {name}!</h1>;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Two-Way Binding</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Use <code className="text-foreground">useDataBinding</code> for
|
||||
read-write access:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useDataBinding } from '@json-render/react';
|
||||
|
||||
function EmailInput() {
|
||||
const [email, setEmail] = useDataBinding('/form/email');
|
||||
|
||||
return (
|
||||
<input
|
||||
type="email"
|
||||
value={email || ''}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Using the Data Context
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Access the full data context for advanced use cases:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useData } from '@json-render/react';
|
||||
|
||||
function DataDebugger() {
|
||||
const { data, setData, getValue, setValue } = useData();
|
||||
|
||||
// Read any path
|
||||
const revenue = getValue('/metrics/revenue');
|
||||
|
||||
// Write any path
|
||||
const updateRevenue = () => setValue('/metrics/revenue', 150000);
|
||||
|
||||
// Replace all data
|
||||
const resetData = () => setData({ user: {}, form: {} });
|
||||
|
||||
return <pre>{JSON.stringify(data, null, 2)}</pre>;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">In JSON UI Trees</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
AI can reference data paths in component props:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "/metrics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/actions" className="text-foreground hover:underline">
|
||||
actions
|
||||
</Link>{" "}
|
||||
for user interactions.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 React Native
|
||||
|
||||
<PackageInstall packages="@json-render/core @json-render/react-native" />
|
||||
|
||||
## 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" />
|
||||
@@ -1,43 +0,0 @@
|
||||
import { PackageInstall } from "@/components/package-install";
|
||||
|
||||
export const metadata = {
|
||||
title: "Installation | json-render",
|
||||
};
|
||||
|
||||
export default function InstallationPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Installation</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Install the core 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,5 +1,7 @@
|
||||
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,
|
||||
@@ -16,8 +18,14 @@ export default function DocsLayout({
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
|
||||
<div className="flex-1 min-w-0 max-w-2xl pb-20">
|
||||
<div className="flex justify-end mb-4">
|
||||
<CopyPageButton />
|
||||
</div>
|
||||
<article>{children}</article>
|
||||
</div>
|
||||
</div>
|
||||
<DocsChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
export const metadata = { title: "OpenAPI Integration" }
|
||||
|
||||
# OpenAPI Integration
|
||||
|
||||
Use json-render to generate dynamic forms and UIs from [OpenAPI/Swagger](https://swagger.io/specification/) schemas.
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can support OpenAPI schemas. The examples are illustrative and may require adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
## Why OpenAPI?
|
||||
|
||||
OpenAPI specifications describe your API's endpoints, request bodies, and response schemas. By converting OpenAPI schemas to json-render specs, you can:
|
||||
|
||||
- Automatically generate forms for API endpoints
|
||||
- Display API responses with type-aware rendering
|
||||
- Keep your UI in sync with your API schema
|
||||
- Let AI generate UIs that match your API contracts
|
||||
|
||||
## Example OpenAPI Schema
|
||||
|
||||
A typical OpenAPI schema for a request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"summary": "Create a new user",
|
||||
"operationId": "createUser",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateUserRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "User's full name",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"age": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 150,
|
||||
"description": "User's age"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "user", "guest"],
|
||||
"default": "user",
|
||||
"description": "User's role"
|
||||
},
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"newsletter": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark", "system"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Define an OpenAPI-to-UI Catalog
|
||||
|
||||
Create components that map to OpenAPI data types:
|
||||
|
||||
```typescript
|
||||
import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = createCatalog({
|
||||
components: {
|
||||
Form: {
|
||||
description: 'API form container',
|
||||
props: z.object({
|
||||
operationId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
StringField: {
|
||||
description: 'String input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
pattern: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
NumberField: {
|
||||
description: 'Number input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.enum(['integer', 'number']).optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
defaultValue: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
BooleanField: {
|
||||
description: 'Boolean toggle field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
EnumField: {
|
||||
description: 'Enum selection field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
options: z.array(z.object({
|
||||
value: z.string(),
|
||||
label: z.string().optional(),
|
||||
})),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
ObjectField: {
|
||||
description: 'Nested object group',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
collapsible: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: {
|
||||
description: 'Submit form to API endpoint',
|
||||
params: z.object({ operationId: z.string() }),
|
||||
},
|
||||
reset: {
|
||||
description: 'Reset form to defaults',
|
||||
params: z.object({}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Convert OpenAPI Schema to Spec
|
||||
|
||||
Transform OpenAPI schemas into json-render specs by recursively walking the schema properties and mapping each type to the corresponding catalog component. The converter handles nested objects, enums, arrays, and all primitive types.
|
||||
|
||||
## Usage Example
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { OpenAPIForm } from './openapi-form';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
// Your OpenAPI schema (typically loaded from your API)
|
||||
const createUserSchema = {
|
||||
type: 'object',
|
||||
required: ['email', 'name'],
|
||||
properties: {
|
||||
name: { type: 'string', description: "User's full name" },
|
||||
email: { type: 'string', format: 'email', description: "User's email" },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to spec
|
||||
const spec = operationToSpec(
|
||||
'createUser',
|
||||
'POST',
|
||||
'/api/users',
|
||||
createUserSchema,
|
||||
'Create User',
|
||||
'Add a new user to the system',
|
||||
);
|
||||
|
||||
export function CreateUserForm() {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('User created!');
|
||||
}
|
||||
};
|
||||
|
||||
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-generating from OpenAPI Document
|
||||
|
||||
Load and parse an OpenAPI document to generate forms for all operations:
|
||||
|
||||
```typescript
|
||||
import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
export async function loadOpenAPISpecs(specUrl: string) {
|
||||
const api = await SwaggerParser.dereference(specUrl);
|
||||
const specs: Record<string, any> = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(api.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
|
||||
|
||||
const schema = operation.requestBody.content['application/json'].schema;
|
||||
const operationId = operation.operationId || `${method}_${path.replace(/\//g, '_')}`;
|
||||
|
||||
specs[operationId] = operationToSpec(
|
||||
operationId,
|
||||
method,
|
||||
path,
|
||||
schema,
|
||||
operation.summary,
|
||||
operation.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
|
||||
// specs.createUser, specs.updateUser, etc.
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [streaming](/docs/streaming) for progressive UI rendering.
|
||||
@@ -1,718 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "OpenAPI Integration | json-render",
|
||||
};
|
||||
|
||||
export default function OpenAPIPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Use json-render to generate dynamic forms and UIs from{" "}
|
||||
<a
|
||||
href="https://swagger.io/specification/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
OpenAPI/Swagger
|
||||
</a>{" "}
|
||||
schemas.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||
<strong>Concept:</strong> This page demonstrates how json-render can
|
||||
support OpenAPI schemas. The examples are illustrative and may require
|
||||
adaptation for production use.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
OpenAPI specifications describe your API{"'"}s endpoints, request
|
||||
bodies, and response schemas. By converting OpenAPI schemas to
|
||||
json-render specs, you can:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Automatically generate forms for API endpoints</li>
|
||||
<li>Display API responses with type-aware rendering</li>
|
||||
<li>Keep your UI in sync with your API schema</li>
|
||||
<li>Let AI generate UIs that match your API contracts</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Example OpenAPI Schema
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A typical OpenAPI schema for a request body:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"openapi": "3.0.0",
|
||||
"paths": {
|
||||
"/users": {
|
||||
"post": {
|
||||
"summary": "Create a new user",
|
||||
"operationId": "createUser",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CreateUserRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"CreateUserRequest": {
|
||||
"type": "object",
|
||||
"required": ["email", "name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "User's full name",
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"format": "email",
|
||||
"description": "User's email address"
|
||||
},
|
||||
"age": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 150,
|
||||
"description": "User's age"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["admin", "user", "guest"],
|
||||
"default": "user",
|
||||
"description": "User's role"
|
||||
},
|
||||
"preferences": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"newsletter": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"theme": {
|
||||
"type": "string",
|
||||
"enum": ["light", "dark", "system"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Define an OpenAPI-to-UI Catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create components that map to OpenAPI data types:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const openapiCatalog = createCatalog({
|
||||
components: {
|
||||
// Form container
|
||||
Form: {
|
||||
description: 'API form container',
|
||||
props: z.object({
|
||||
operationId: z.string(),
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Field components mapped to OpenAPI types
|
||||
StringField: {
|
||||
description: 'String input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
pattern: z.string().optional(),
|
||||
placeholder: z.string().optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
NumberField: {
|
||||
description: 'Number input field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.enum(['integer', 'number']).optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
exclusiveMinimum: z.number().optional(),
|
||||
exclusiveMaximum: z.number().optional(),
|
||||
multipleOf: z.number().optional(),
|
||||
defaultValue: z.number().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
BooleanField: {
|
||||
description: 'Boolean toggle field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
EnumField: {
|
||||
description: 'Enum selection field',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
required: z.boolean().optional(),
|
||||
options: z.array(z.object({
|
||||
value: z.string(),
|
||||
label: z.string().optional(),
|
||||
})),
|
||||
defaultValue: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ArrayField: {
|
||||
description: 'Array of items',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
minItems: z.number().optional(),
|
||||
maxItems: z.number().optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
ObjectField: {
|
||||
description: 'Nested object group',
|
||||
props: z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
collapsible: z.boolean().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// Response display components
|
||||
ResponseDisplay: {
|
||||
description: 'Displays API response',
|
||||
props: z.object({
|
||||
status: z.number(),
|
||||
statusText: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
SchemaTable: {
|
||||
description: 'Displays data matching a schema',
|
||||
props: z.object({
|
||||
schema: z.string(),
|
||||
data: z.array(z.record(z.unknown())),
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit: {
|
||||
description: 'Submit form to API endpoint',
|
||||
params: z.object({
|
||||
operationId: z.string(),
|
||||
}),
|
||||
},
|
||||
reset: {
|
||||
description: 'Reset form to defaults',
|
||||
params: z.object({}),
|
||||
},
|
||||
},
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Convert OpenAPI Schema to Spec
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Transform OpenAPI schemas into json-render specs:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
enum?: string[];
|
||||
properties?: Record<string, OpenAPISchema>;
|
||||
items?: OpenAPISchema;
|
||||
required?: string[];
|
||||
description?: string;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
interface SpecElement {
|
||||
key: string;
|
||||
type: string;
|
||||
props: Record<string, unknown>;
|
||||
children: string[];
|
||||
parentKey: string;
|
||||
}
|
||||
|
||||
function schemaToSpec(
|
||||
schema: OpenAPISchema,
|
||||
name: string,
|
||||
required: string[] = [],
|
||||
parentKey: string = '',
|
||||
elements: Map<string, SpecElement> = new Map(),
|
||||
): string {
|
||||
const key = parentKey ? \`\${parentKey}-\${name}\` : name;
|
||||
const isRequired = required.includes(name);
|
||||
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
|
||||
|
||||
if (schema.enum) {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'EnumField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
options: schema.enum.map(v => ({ value: v, label: v })),
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'string') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'StringField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
format: schema.format || 'text',
|
||||
minLength: schema.minLength,
|
||||
maxLength: schema.maxLength,
|
||||
defaultValue: schema.default as string,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'integer' || schema.type === 'number') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'NumberField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
required: isRequired,
|
||||
type: schema.type,
|
||||
minimum: schema.minimum,
|
||||
maximum: schema.maximum,
|
||||
defaultValue: schema.default as number,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'boolean') {
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'BooleanField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
defaultValue: schema.default as boolean,
|
||||
},
|
||||
children: [],
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'array' && schema.items) {
|
||||
const childKeys: string[] = [];
|
||||
const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
|
||||
childKeys.push(itemKey);
|
||||
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'ArrayField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey,
|
||||
});
|
||||
} else if (schema.type === 'object' && schema.properties) {
|
||||
const childKeys: string[] = [];
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
const childKey = schemaToSpec(
|
||||
propSchema,
|
||||
propName,
|
||||
schema.required || [],
|
||||
key,
|
||||
elements,
|
||||
);
|
||||
childKeys.push(childKey);
|
||||
}
|
||||
|
||||
elements.set(key, {
|
||||
key,
|
||||
type: 'ObjectField',
|
||||
props: {
|
||||
name,
|
||||
label,
|
||||
description: schema.description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey,
|
||||
});
|
||||
}
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
// Convert full OpenAPI operation to spec
|
||||
export function operationToSpec(
|
||||
operationId: string,
|
||||
method: string,
|
||||
path: string,
|
||||
schema: OpenAPISchema,
|
||||
title?: string,
|
||||
description?: string,
|
||||
) {
|
||||
const elements = new Map<string, SpecElement>();
|
||||
const rootKey = 'form';
|
||||
const childKeys: string[] = [];
|
||||
|
||||
if (schema.properties) {
|
||||
for (const [name, propSchema] of Object.entries(schema.properties)) {
|
||||
const childKey = schemaToSpec(
|
||||
propSchema,
|
||||
name,
|
||||
schema.required || [],
|
||||
rootKey,
|
||||
elements,
|
||||
);
|
||||
childKeys.push(childKey);
|
||||
}
|
||||
}
|
||||
|
||||
elements.set(rootKey, {
|
||||
key: rootKey,
|
||||
type: 'Form',
|
||||
props: {
|
||||
operationId,
|
||||
endpoint: path,
|
||||
method: method.toUpperCase(),
|
||||
title,
|
||||
description,
|
||||
},
|
||||
children: childKeys,
|
||||
parentKey: '',
|
||||
});
|
||||
|
||||
return {
|
||||
root: rootKey,
|
||||
elements: Object.fromEntries(elements),
|
||||
};
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Build an OpenAPI Form Renderer
|
||||
</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface FieldProps {
|
||||
name: string;
|
||||
value: unknown;
|
||||
onChange: (name: string, value: unknown) => void;
|
||||
}
|
||||
|
||||
const fields: Record<string, React.FC<any>> = {
|
||||
StringField: ({ name, label, description, required, format, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<input
|
||||
type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<input
|
||||
type="number"
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as number) ?? ''}
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
BooleanField: ({ name, label, description, value, onChange }) => (
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={name}
|
||||
checked={Boolean(value)}
|
||||
onChange={(e) => onChange(name, e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<label htmlFor={name} className="text-sm font-medium">{label}</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
EnumField: ({ name, label, description, required, options, value, onChange }) => (
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">
|
||||
{label} {required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
<select
|
||||
className="w-full px-3 py-2 border rounded text-sm"
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(name, e.target.value)}
|
||||
required={required}
|
||||
>
|
||||
<option value="">Select...</option>
|
||||
{options?.map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label || opt.value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
),
|
||||
|
||||
ObjectField: ({ name, label, description, children }) => (
|
||||
<fieldset className="border rounded p-4 space-y-4">
|
||||
<legend className="text-sm font-medium px-2">{label}</legend>
|
||||
{description && <p className="text-xs text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</fieldset>
|
||||
),
|
||||
|
||||
Form: ({ title, description, endpoint, method, children, onSubmit }) => (
|
||||
<form
|
||||
className="space-y-4 max-w-md"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit?.();
|
||||
}}
|
||||
>
|
||||
{title && <h2 className="text-lg font-semibold">{title}</h2>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
|
||||
>
|
||||
{method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
|
||||
</button>
|
||||
</form>
|
||||
),
|
||||
};
|
||||
|
||||
interface OpenAPIFormProps {
|
||||
spec: {
|
||||
root: string;
|
||||
elements: Record<string, any>;
|
||||
};
|
||||
onSubmit: (data: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleChange = (name: string, value: unknown) => {
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
function renderElement(key: string): React.ReactNode {
|
||||
const element = spec.elements[key];
|
||||
if (!element) return null;
|
||||
|
||||
const Field = fields[element.type];
|
||||
if (!Field) return null;
|
||||
|
||||
const children = element.children?.map(renderElement);
|
||||
|
||||
return (
|
||||
<Field
|
||||
key={key}
|
||||
{...element.props}
|
||||
value={formData[element.props.name]}
|
||||
onChange={handleChange}
|
||||
onSubmit={() => onSubmit(formData)}
|
||||
>
|
||||
{children}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{renderElement(spec.root)}</>;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
|
||||
<Code lang="tsx">{`'use client';
|
||||
|
||||
import { OpenAPIForm } from './openapi-form';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
// Your OpenAPI schema (typically loaded from your API)
|
||||
const createUserSchema = {
|
||||
type: 'object',
|
||||
required: ['email', 'name'],
|
||||
properties: {
|
||||
name: { type: 'string', description: "User's full name" },
|
||||
email: { type: 'string', format: 'email', description: "User's email" },
|
||||
age: { type: 'integer', minimum: 0, maximum: 150 },
|
||||
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
|
||||
},
|
||||
};
|
||||
|
||||
// Convert to spec
|
||||
const spec = operationToSpec(
|
||||
'createUser',
|
||||
'POST',
|
||||
'/api/users',
|
||||
createUserSchema,
|
||||
'Create User',
|
||||
'Add a new user to the system',
|
||||
);
|
||||
|
||||
export function CreateUserForm() {
|
||||
const handleSubmit = async (data: Record<string, unknown>) => {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
console.log('User created!');
|
||||
}
|
||||
};
|
||||
|
||||
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Auto-generating from OpenAPI Document
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Load and parse an OpenAPI document to generate forms for all operations:
|
||||
</p>
|
||||
<Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
|
||||
import { operationToSpec } from './openapi-to-spec';
|
||||
|
||||
interface OpenAPIDocument {
|
||||
paths: Record<string, Record<string, {
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
requestBody?: {
|
||||
content?: {
|
||||
'application/json'?: {
|
||||
schema?: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>>;
|
||||
components?: {
|
||||
schemas?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadOpenAPISpecs(specUrl: string) {
|
||||
const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
|
||||
const specs: Record<string, any> = {};
|
||||
|
||||
for (const [path, methods] of Object.entries(api.paths)) {
|
||||
for (const [method, operation] of Object.entries(methods)) {
|
||||
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
|
||||
|
||||
const schema = operation.requestBody.content['application/json'].schema;
|
||||
const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
|
||||
|
||||
specs[operationId] = operationToSpec(
|
||||
operationId,
|
||||
method,
|
||||
path,
|
||||
schema,
|
||||
operation.summary,
|
||||
operation.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return specs;
|
||||
}
|
||||
|
||||
// Usage
|
||||
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
|
||||
// specs.createUser, specs.updateUser, etc.`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link
|
||||
href="/docs/streaming"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
streaming
|
||||
</Link>{" "}
|
||||
for progressive UI rendering.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export const metadata = { title: "Introduction" }
|
||||
|
||||
# Introduction
|
||||
|
||||
The framework for User-Generated Interfaces (UGI). Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
|
||||
## What is json-render?
|
||||
|
||||
json-render is the framework for **User-Generated Interfaces**: dynamic UIs that end users generate through natural language prompts, powered by Generative UI. You define 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 on web or mobile.
|
||||
|
||||
Every interface is unique to the user, but every interface is safe and predictable.
|
||||
|
||||
## Why json-render?
|
||||
|
||||
### Guardrailed
|
||||
|
||||
AI can only use components in your catalog. No arbitrary code generation. Predefined components and actions for safe, predictable output.
|
||||
|
||||
### 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.
|
||||
|
||||
### Cross-Platform
|
||||
|
||||
Render on web with React and on mobile with React Native from the same catalog and spec format.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Define the guardrails - what components, actions, and data bindings AI can use
|
||||
2. Users generate - 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
|
||||
@@ -1,67 +0,0 @@
|
||||
export const metadata = {
|
||||
title: "Introduction | json-render",
|
||||
};
|
||||
|
||||
export default function DocsPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Introduction</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Predictable. Guardrailed. Fast. Let users generate dashboards, widgets,
|
||||
apps, and data visualizations from prompts.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is json-render?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
|
||||
json-render lets end users generate UI from natural language prompts —
|
||||
safely constrained to components you define. You set the guardrails:
|
||||
what components exist, what props they take, what actions are available.
|
||||
AI generates JSON that matches your schema, and your components render
|
||||
it natively.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
|
||||
<div className="space-y-4 mb-8">
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Guardrailed</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI can only use components in your catalog. No arbitrary code
|
||||
generation.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Predictable</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
JSON output matches your schema, every time. Actions are declared by
|
||||
name, you control what they do.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium mb-1">Fast</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Stream and render progressively as the model responds. No waiting
|
||||
for completion.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">How it works</h2>
|
||||
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
|
||||
<li>
|
||||
Define the guardrails — what components, actions, and data bindings AI
|
||||
can use
|
||||
</li>
|
||||
<li>
|
||||
Users prompt — end users describe what they want in natural language
|
||||
</li>
|
||||
<li>
|
||||
AI generates JSON — output is always predictable, constrained to your
|
||||
catalog
|
||||
</li>
|
||||
<li>
|
||||
Render fast — stream and render progressively as the model responds
|
||||
</li>
|
||||
</ol>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export const metadata = { title: "Quick Start" }
|
||||
|
||||
# Quick Start
|
||||
|
||||
Get up and running with json-render in 5 minutes.
|
||||
|
||||
## 1. Define your catalog
|
||||
|
||||
Create a catalog that defines what components AI can use:
|
||||
|
||||
```typescript
|
||||
// lib/catalog.ts
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Container card with optional title",
|
||||
},
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
action: z.string().nullable(),
|
||||
}),
|
||||
description: "Clickable button that triggers an action",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
}),
|
||||
description: "Text paragraph",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
submit: {
|
||||
params: z.object({ formId: z.string() }),
|
||||
description: "Submit a form",
|
||||
},
|
||||
navigate: {
|
||||
params: z.object({ url: z.string() }),
|
||||
description: "Navigate to a URL",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## 2. Define your components
|
||||
|
||||
Use `defineRegistry` to map catalog types to React components. Each component receives type-safe `props`, `children`, and `emit`:
|
||||
|
||||
```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, emit }) => (
|
||||
<button
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded"
|
||||
onClick={() => emit?.("press")}
|
||||
>
|
||||
{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)
|
||||
@@ -1,220 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Quick Start | json-render",
|
||||
};
|
||||
|
||||
export default function QuickStartPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Quick Start</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Get up and running with json-render in 5 minutes.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
1. Define your catalog
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Create a catalog that defines what components AI can use:
|
||||
</p>
|
||||
<Code lang="typescript">{`// lib/catalog.ts
|
||||
import { 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
export const metadata = { title: "Registry" }
|
||||
|
||||
# Registry
|
||||
|
||||
Register React components and action handlers to bring your catalog to life.
|
||||
|
||||
## defineRegistry
|
||||
|
||||
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
|
||||
|
||||
```tsx
|
||||
import { defineRegistry } from '@json-render/react';
|
||||
import { myCatalog } from './catalog';
|
||||
|
||||
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) => (
|
||||
<div className="card">
|
||||
<h2>{props.title}</h2>
|
||||
{props.description && <p>{props.description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction }) => (
|
||||
<button onClick={() => onAction?.({ name: props.action })}>
|
||||
{props.label}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
|
||||
actions: {
|
||||
submit_form: async (params, setState) => {
|
||||
const res = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
const result = await res.json();
|
||||
setState((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, `export.${params.format}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The returned object contains:
|
||||
|
||||
- `registry` - component registry for `<Renderer />`
|
||||
- `handlers` - factory for ActionProvider-compatible handlers
|
||||
- `executeAction` - imperative action dispatch (for use outside the React tree)
|
||||
|
||||
## Component Props
|
||||
|
||||
Each component in the registry receives a `ComponentContext` object:
|
||||
|
||||
```typescript
|
||||
interface ComponentContext {
|
||||
props: T; // Type-safe props from your catalog
|
||||
children?: React.ReactNode; // Rendered children (for slot components)
|
||||
onAction?: (action: ActionTrigger) => void; // Dispatch an action
|
||||
loading?: boolean; // Whether the renderer is in a loading state
|
||||
}
|
||||
```
|
||||
|
||||
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
|
||||
|
||||
## Action Handlers
|
||||
|
||||
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
|
||||
|
||||
### Defining Actions
|
||||
|
||||
Define available actions in your catalog:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
actions: {
|
||||
submit_form: {
|
||||
params: z.object({
|
||||
formId: z.string(),
|
||||
}),
|
||||
description: 'Submit a form',
|
||||
},
|
||||
export_data: {
|
||||
params: z.object({
|
||||
format: z.enum(['csv', 'pdf', 'json']),
|
||||
}),
|
||||
},
|
||||
navigate: {
|
||||
params: z.object({
|
||||
url: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Implementing Action Handlers
|
||||
|
||||
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
|
||||
|
||||
```tsx
|
||||
export const { handlers, executeAction } = defineRegistry(catalog, {
|
||||
actions: {
|
||||
submit_form: async (params, setState) => {
|
||||
const response = await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ formId: params.formId }),
|
||||
});
|
||||
const result = await response.json();
|
||||
setState((prev) => ({ ...prev, formResult: result }));
|
||||
},
|
||||
|
||||
export_data: async (params) => {
|
||||
const blob = await generateExport(params.format);
|
||||
downloadBlob(blob, `export.${params.format}`);
|
||||
},
|
||||
|
||||
navigate: (params) => {
|
||||
window.location.href = params.url;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Using Data Binding
|
||||
|
||||
Use hooks inside your registry components to read and write data:
|
||||
|
||||
```tsx
|
||||
import { useStateStore } from '@json-render/react';
|
||||
import { getByPath } from '@json-render/core';
|
||||
|
||||
// Inside defineRegistry components:
|
||||
|
||||
Metric: ({ props }) => {
|
||||
const { data } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath);
|
||||
|
||||
return (
|
||||
<div className="metric">
|
||||
<span className="label">{props.label}</span>
|
||||
<span className="value">{formatValue(value)}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
TextField: ({ props }) => {
|
||||
const { data, set } = useStateStore();
|
||||
const value = getByPath(data, props.valuePath) as string;
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
## Using the Renderer
|
||||
|
||||
Wire everything together with providers and the `<Renderer />` component:
|
||||
|
||||
```tsx
|
||||
import { useMemo, useRef } from 'react';
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from '@json-render/react';
|
||||
import { registry, handlers } from './registry';
|
||||
|
||||
function App({ spec, data, setState }) {
|
||||
const dataRef = useRef(data);
|
||||
const setStateRef = useRef(setState);
|
||||
dataRef.current = data;
|
||||
setStateRef.current = setState;
|
||||
|
||||
const actionHandlers = useMemo(
|
||||
() => handlers(() => setStateRef.current, () => dataRef.current),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<StateProvider initialState={data}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [data binding](/docs/data-binding) for dynamic values.
|
||||
@@ -1,248 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
export const metadata = { title: "Schemas" }
|
||||
|
||||
# Schemas
|
||||
|
||||
Schemas define the structure and validation rules for your UI specs.
|
||||
|
||||
## What is a Schema?
|
||||
|
||||
A schema defines the JSON structure that describes your UI. It includes:
|
||||
|
||||
- **Element structure** — How components are nested and referenced
|
||||
- **Property types** — What props each component accepts
|
||||
- **Data binding syntax** — How to reference dynamic data
|
||||
- **Action format** — How user interactions are defined
|
||||
|
||||
## Schema-Agnostic by Design
|
||||
|
||||
json-render can work with any JSON schema. `@json-render/core` provides the primitives to define catalogs and renderers for any format:
|
||||
|
||||
- **@json-render/react** — The built-in flat element tree schema
|
||||
- **[A2UI](/docs/a2ui)** — Google's Agent-to-User Interaction protocol
|
||||
- **[Adaptive Cards](/docs/adaptive-cards)** — Microsoft's platform-agnostic UI format
|
||||
- **AG-UI** — CopilotKit's Agent User Interaction Protocol
|
||||
- **OpenAPI/Swagger** — API documentation schemas for dynamic forms
|
||||
- **Custom schemas** — Design your own format tailored to your domain
|
||||
|
||||
See the [Custom Schema guide](/docs/custom-schema) to learn how to implement support for any schema.
|
||||
|
||||
## Built-in Schema
|
||||
|
||||
`@json-render/react` uses a flat element tree schema with a root key and elements map:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Dashboard" },
|
||||
"children": ["text-1", "button-1"]
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Welcome, $data.user.name" },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
"type": "Button",
|
||||
"props": { "label": "Click me" },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Schema Components
|
||||
|
||||
### Element Structure
|
||||
|
||||
In the built-in schema, each element in the elements map has this structure:
|
||||
|
||||
```typescript
|
||||
interface Element {
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
}
|
||||
```
|
||||
|
||||
### Data Binding Syntax
|
||||
|
||||
Reference dynamic data using the `$data` prefix in props:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Action Format
|
||||
|
||||
Actions are defined in the catalog and referenced from components. The renderer handles action execution:
|
||||
|
||||
```typescript
|
||||
// In your catalog
|
||||
actions: {
|
||||
navigate: {
|
||||
params: z.object({ url: z.string() }),
|
||||
description: 'Navigate to a URL',
|
||||
},
|
||||
apiCall: {
|
||||
params: z.object({
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
|
||||
}),
|
||||
description: 'Make an API request',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Schemas
|
||||
|
||||
`@json-render/core` is schema-agnostic. You can define any JSON structure:
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
// Define your own element schema
|
||||
const MyElementSchema = z.object({
|
||||
component: z.string(),
|
||||
settings: z.record(z.unknown()),
|
||||
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
|
||||
});
|
||||
|
||||
// Define your own data binding format
|
||||
const BoundValue = z.object({
|
||||
literal: z.string().optional(),
|
||||
path: z.string().optional(), // e.g., "/users/0/name"
|
||||
});
|
||||
|
||||
// Define your own action format
|
||||
const ActionSchema = z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.unknown()).optional(),
|
||||
});
|
||||
```
|
||||
|
||||
## Schema vs Catalog
|
||||
|
||||
The schema and catalog work together but serve different purposes:
|
||||
|
||||
- **Schema** — Defines the JSON structure (how elements are organized)
|
||||
- **Catalog** — Defines available components and their props (what can be used)
|
||||
|
||||
The schema is the grammar; the catalog is the vocabulary.
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [specs](/docs/specs) — the actual JSON documents that describe your UI.
|
||||
@@ -1,230 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Schemas | json-render",
|
||||
};
|
||||
|
||||
export default function SchemasPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Schemas</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Schemas define the structure and validation rules for your UI specs.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A schema defines the JSON structure that describes your UI. It includes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Element structure</strong> — How
|
||||
components are nested and referenced
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Property types</strong> — What
|
||||
props each component accepts
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Data binding syntax</strong> — How
|
||||
to reference dynamic data
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Action format</strong> — How user
|
||||
interactions are defined
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Schema-Agnostic by Design
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render can work with any JSON schema.{" "}
|
||||
<code className="text-foreground">@json-render/core</code> provides the
|
||||
primitives to define catalogs and renderers for any format:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">@json-render/react</strong> — The
|
||||
built-in flat element tree schema
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">
|
||||
<Link href="/docs/a2ui" className="hover:underline">
|
||||
A2UI
|
||||
</Link>
|
||||
</strong>{" "}
|
||||
— Google{"'"}s Agent-to-User Interaction protocol
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">
|
||||
<Link href="/docs/adaptive-cards" className="hover:underline">
|
||||
Adaptive Cards
|
||||
</Link>
|
||||
</strong>{" "}
|
||||
— Microsoft{"'"}s platform-agnostic UI format
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
|
||||
Agent User Interaction Protocol
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">OpenAPI/Swagger</strong> — API
|
||||
documentation schemas for dynamic forms
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Custom schemas</strong> — Design
|
||||
your own format tailored to your domain
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
See the{" "}
|
||||
<Link
|
||||
href="/docs/custom-schema"
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
Custom Schema guide
|
||||
</Link>{" "}
|
||||
to learn how to implement support for any schema.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/react</code> uses a flat
|
||||
element tree schema with a root key and elements map:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "Dashboard" },
|
||||
"children": ["text-1", "button-1"],
|
||||
"parentKey": ""
|
||||
},
|
||||
"text-1": {
|
||||
"key": "text-1",
|
||||
"type": "Text",
|
||||
"props": { "content": "Welcome, $data.user.name" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
},
|
||||
"button-1": {
|
||||
"key": "button-1",
|
||||
"type": "Button",
|
||||
"props": { "label": "Click me" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
In the built-in schema, each element in the elements map has this
|
||||
structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`interface Element {
|
||||
key: string; // Unique identifier
|
||||
type: string; // Component type from catalog
|
||||
props: Record<string, any>; // Component properties
|
||||
children: string[]; // Array of child element keys
|
||||
parentKey: string; // Parent element key (empty for root)
|
||||
visible?: VisibilityRule; // Conditional display
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Reference dynamic data using the{" "}
|
||||
<code className="text-foreground">$data</code> prefix in props:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "greeting",
|
||||
"type": "Text",
|
||||
"props": {
|
||||
"content": "$data.user.name",
|
||||
"count": "$data.items.length"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Actions are defined in the catalog and referenced from components. The
|
||||
renderer handles action execution:
|
||||
</p>
|
||||
<Code lang="typescript">{`// In your catalog
|
||||
actions: {
|
||||
navigate: {
|
||||
params: z.object({ url: z.string() }),
|
||||
description: 'Navigate to a URL',
|
||||
},
|
||||
apiCall: {
|
||||
params: z.object({
|
||||
endpoint: z.string(),
|
||||
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
|
||||
}),
|
||||
description: 'Make an API request',
|
||||
},
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
<code className="text-foreground">@json-render/core</code> is
|
||||
schema-agnostic. You can define any JSON structure:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { z } from 'zod';
|
||||
|
||||
// Define your own element schema
|
||||
const MyElementSchema = z.object({
|
||||
component: z.string(),
|
||||
settings: z.record(z.unknown()),
|
||||
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
|
||||
});
|
||||
|
||||
// Define your own data binding format
|
||||
const BoundValue = z.object({
|
||||
literal: z.string().optional(),
|
||||
path: z.string().optional(), // e.g., "/users/0/name"
|
||||
});
|
||||
|
||||
// Define your own action format
|
||||
const ActionSchema = z.object({
|
||||
name: z.string(),
|
||||
context: z.record(z.unknown()).optional(),
|
||||
});`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema and catalog work together but serve different purposes:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">Schema</strong> — Defines the JSON
|
||||
structure (how elements are organized)
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Catalog</strong> — Defines
|
||||
available components and their props (what can be used)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The schema is the grammar; the catalog is the vocabulary.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/specs" className="text-foreground hover:underline">
|
||||
specs
|
||||
</Link>{" "}
|
||||
— the actual JSON documents that describe your UI.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
export const metadata = { title: "Specs" }
|
||||
|
||||
# Specs
|
||||
|
||||
A spec is a JSON document that describes your UI.
|
||||
|
||||
## What is a Spec?
|
||||
|
||||
A spec (specification) is the actual JSON that describes a UI. It conforms to a [schema](/docs/schemas) and uses components from a [catalog](/docs/catalog). Specs can be:
|
||||
|
||||
- Generated by AI in real-time
|
||||
- Stored in a database
|
||||
- Streamed progressively from a server
|
||||
- Hand-authored as JSON files
|
||||
|
||||
json-render is schema-agnostic — your specs can follow any JSON structure you choose.
|
||||
|
||||
## Example Specs
|
||||
|
||||
### Simple Spec
|
||||
|
||||
A basic spec using the `@json-render/react` schema. Note the flat structure with a `root` key and `elements` map:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "Welcome" },
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": {
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Complex Spec
|
||||
|
||||
A more complex spec with multiple nested elements:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "User Profile", "padding": "md" },
|
||||
"children": ["row-1", "button-1"]
|
||||
},
|
||||
"row-1": {
|
||||
"type": "Row",
|
||||
"props": { "gap": "md" },
|
||||
"children": ["avatar-1", "stack-1"]
|
||||
},
|
||||
"avatar-1": {
|
||||
"type": "Avatar",
|
||||
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||
"children": []
|
||||
},
|
||||
"stack-1": {
|
||||
"type": "Stack",
|
||||
"props": { "gap": "sm" },
|
||||
"children": ["name-text", "email-text"]
|
||||
},
|
||||
"name-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||
"children": []
|
||||
},
|
||||
"email-text": {
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||
"children": []
|
||||
},
|
||||
"button-1": {
|
||||
"type": "Button",
|
||||
"props": { "label": "Edit Profile" },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Block-Level Spec
|
||||
|
||||
A high-level spec using semantic blocks for page layouts:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "page",
|
||||
"elements": {
|
||||
"page": {
|
||||
"type": "Page",
|
||||
"props": {},
|
||||
"children": ["header", "hero", "features", "footer"]
|
||||
},
|
||||
"header": {
|
||||
"type": "Header",
|
||||
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
|
||||
"children": []
|
||||
},
|
||||
"hero": {
|
||||
"type": "Hero",
|
||||
"props": {
|
||||
"title": "Build UIs with JSON",
|
||||
"subtitle": "Let AI generate your interfaces",
|
||||
"ctaLabel": "Get Started",
|
||||
"ctaHref": "/docs"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
"features": {
|
||||
"type": "Features",
|
||||
"props": { "columns": 3 },
|
||||
"children": ["feature-1", "feature-2", "feature-3"]
|
||||
},
|
||||
"feature-1": {
|
||||
"type": "Feature",
|
||||
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
|
||||
"children": []
|
||||
},
|
||||
"feature-2": {
|
||||
"type": "Feature",
|
||||
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
|
||||
"children": []
|
||||
},
|
||||
"feature-3": {
|
||||
"type": "Feature",
|
||||
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
|
||||
"children": []
|
||||
},
|
||||
"footer": {
|
||||
"type": "Footer",
|
||||
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
|
||||
"children": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Spec Anatomy
|
||||
|
||||
### Root and Elements
|
||||
|
||||
Every spec has a `root` key pointing to the entry element, and an `elements` map containing all elements:
|
||||
|
||||
```json
|
||||
{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"type": "Card",
|
||||
"props": { "title": "My Card" },
|
||||
"children": ["text-1"]
|
||||
},
|
||||
"text-1": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Element Structure
|
||||
|
||||
Each element in the map has a consistent shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ComponentName",
|
||||
"props": { "label": "Hello" },
|
||||
"children": ["child-1", "child-2"]
|
||||
}
|
||||
```
|
||||
|
||||
- `type` — Component type from your catalog
|
||||
- `props` — Component properties
|
||||
- `children` — Array of child element keys
|
||||
|
||||
### Dynamic Data
|
||||
|
||||
Props can reference data using `$data` paths:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Visibility
|
||||
|
||||
Control when elements appear using the `visible` property:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": {
|
||||
"message": "You have unsaved changes"
|
||||
},
|
||||
"children": [],
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Working with Specs
|
||||
|
||||
### Rendering a Spec
|
||||
|
||||
```tsx
|
||||
import { Renderer } from '@json-render/react';
|
||||
|
||||
function MyApp({ spec, data }) {
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
registry={registry}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Validating a Spec
|
||||
|
||||
```typescript
|
||||
import { validate } from '@json-render/core';
|
||||
|
||||
const result = validate(spec, catalog);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.errors);
|
||||
}
|
||||
```
|
||||
|
||||
### Streaming Specs
|
||||
|
||||
Specs can be streamed incrementally for progressive rendering:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Spec Sources
|
||||
|
||||
Specs can come from various sources:
|
||||
|
||||
- **AI Generation** — LLMs generate specs based on prompts and catalog
|
||||
- **Database** — Store specs as JSON and load dynamically
|
||||
- **API Response** — Server returns specs based on user/context
|
||||
- **Static Files** — Pre-built specs for known UI patterns
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [catalogs](/docs/catalog) — the vocabulary of components available in your specs.
|
||||
@@ -1,369 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Specs | json-render",
|
||||
};
|
||||
|
||||
export default function SpecsPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Specs</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
A spec is a JSON document that describes your UI.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A spec (specification) is the actual JSON that describes a UI. It
|
||||
conforms to a{" "}
|
||||
<Link href="/docs/schemas" className="text-foreground hover:underline">
|
||||
schema
|
||||
</Link>{" "}
|
||||
and uses components from a{" "}
|
||||
<Link href="/docs/catalog" className="text-foreground hover:underline">
|
||||
catalog
|
||||
</Link>
|
||||
. Specs can be:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>Generated by AI in real-time</li>
|
||||
<li>Stored in a database</li>
|
||||
<li>Streamed progressively from a server</li>
|
||||
<li>Hand-authored as JSON files</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render is schema-agnostic — your specs can follow any JSON
|
||||
structure you choose.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A basic spec using the{" "}
|
||||
<code className="text-foreground">@json-render/react</code> schema. Note
|
||||
the flat structure with a <code className="text-foreground">root</code>{" "}
|
||||
key and <code className="text-foreground">elements</code> map:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "Welcome" },
|
||||
"children": ["text-1"],
|
||||
"parentKey": ""
|
||||
},
|
||||
"text-1": {
|
||||
"key": "text-1",
|
||||
"type": "Text",
|
||||
"props": { "content": "Hello, $data.user.name!" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A more complex spec with multiple nested elements:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "User Profile", "padding": "md" },
|
||||
"children": ["row-1", "button-1"],
|
||||
"parentKey": ""
|
||||
},
|
||||
"row-1": {
|
||||
"key": "row-1",
|
||||
"type": "Row",
|
||||
"props": { "gap": "md" },
|
||||
"children": ["avatar-1", "stack-1"],
|
||||
"parentKey": "card-1"
|
||||
},
|
||||
"avatar-1": {
|
||||
"key": "avatar-1",
|
||||
"type": "Avatar",
|
||||
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
|
||||
"children": [],
|
||||
"parentKey": "row-1"
|
||||
},
|
||||
"stack-1": {
|
||||
"key": "stack-1",
|
||||
"type": "Stack",
|
||||
"props": { "gap": "sm" },
|
||||
"children": ["name-text", "email-text"],
|
||||
"parentKey": "row-1"
|
||||
},
|
||||
"name-text": {
|
||||
"key": "name-text",
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.name", "variant": "heading" },
|
||||
"children": [],
|
||||
"parentKey": "stack-1"
|
||||
},
|
||||
"email-text": {
|
||||
"key": "email-text",
|
||||
"type": "Text",
|
||||
"props": { "content": "$data.user.email", "variant": "caption" },
|
||||
"children": [],
|
||||
"parentKey": "stack-1"
|
||||
},
|
||||
"button-1": {
|
||||
"key": "button-1",
|
||||
"type": "Button",
|
||||
"props": { "label": "Edit Profile" },
|
||||
"children": [],
|
||||
"parentKey": "card-1"
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
A high-level spec using semantic blocks for page layouts:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "page",
|
||||
"elements": {
|
||||
"page": {
|
||||
"key": "page",
|
||||
"type": "Page",
|
||||
"props": {},
|
||||
"children": ["header", "hero", "features", "footer"],
|
||||
"parentKey": ""
|
||||
},
|
||||
"header": {
|
||||
"key": "header",
|
||||
"type": "Header",
|
||||
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
},
|
||||
"hero": {
|
||||
"key": "hero",
|
||||
"type": "Hero",
|
||||
"props": {
|
||||
"title": "Build UIs with JSON",
|
||||
"subtitle": "Let AI generate your interfaces",
|
||||
"ctaLabel": "Get Started",
|
||||
"ctaHref": "/docs"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
},
|
||||
"features": {
|
||||
"key": "features",
|
||||
"type": "Features",
|
||||
"props": { "columns": 3 },
|
||||
"children": ["feature-1", "feature-2", "feature-3"],
|
||||
"parentKey": "page"
|
||||
},
|
||||
"feature-1": {
|
||||
"key": "feature-1",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
},
|
||||
"feature-2": {
|
||||
"key": "feature-2",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
},
|
||||
"feature-3": {
|
||||
"key": "feature-3",
|
||||
"type": "Feature",
|
||||
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
|
||||
"children": [],
|
||||
"parentKey": "features"
|
||||
},
|
||||
"footer": {
|
||||
"key": "footer",
|
||||
"type": "Footer",
|
||||
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
|
||||
"children": [],
|
||||
"parentKey": "page"
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Every spec has a <code className="text-foreground">root</code> key
|
||||
pointing to the entry element, and an{" "}
|
||||
<code className="text-foreground">elements</code> map containing all
|
||||
elements:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card-1",
|
||||
"elements": {
|
||||
"card-1": {
|
||||
"key": "card-1",
|
||||
"type": "Card",
|
||||
"props": { "title": "My Card" },
|
||||
"children": ["text-1"],
|
||||
"parentKey": ""
|
||||
},
|
||||
"text-1": { ... }
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Each element in the map has a consistent shape:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "unique-id",
|
||||
"type": "ComponentName",
|
||||
"props": { "label": "Hello" },
|
||||
"children": ["child-1", "child-2"],
|
||||
"parentKey": "parent-id"
|
||||
}`}</Code>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">key</code> — Unique identifier for
|
||||
this element
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">type</code> — Component type from
|
||||
your catalog
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">props</code> — Component properties
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">children</code> — Array of child
|
||||
element keys
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">parentKey</code> — Key of parent
|
||||
element (empty string for root)
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Props can reference data using{" "}
|
||||
<code className="text-foreground">$data</code> paths:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "metric-1",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"value": "$data.metrics.revenue",
|
||||
"change": "$data.metrics.revenueChange"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "dashboard"
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Control when elements appear using the{" "}
|
||||
<code className="text-foreground">visible</code> property:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "alert-1",
|
||||
"type": "Alert",
|
||||
"props": {
|
||||
"message": "You have unsaved changes"
|
||||
},
|
||||
"children": [],
|
||||
"parentKey": "form",
|
||||
"visible": {
|
||||
"path": "$data.form.isDirty",
|
||||
"operator": "eq",
|
||||
"value": true
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
|
||||
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
|
||||
|
||||
function MyApp({ spec, data }) {
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
data={data}
|
||||
registry={registry}
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
|
||||
<Code lang="typescript">{`import { validate } from '@json-render/core';
|
||||
|
||||
const result = validate(spec, catalog);
|
||||
|
||||
if (!result.valid) {
|
||||
console.error('Invalid spec:', result.errors);
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Specs can be streamed incrementally for progressive rendering:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
|
||||
|
||||
function GenerativeUI() {
|
||||
const { spec, isStreaming } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<Renderer
|
||||
spec={spec}
|
||||
registry={registry}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Specs can come from various sources:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<strong className="text-foreground">AI Generation</strong> — LLMs
|
||||
generate specs based on prompts and catalog
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Database</strong> — Store specs as
|
||||
JSON and load dynamically
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">API Response</strong> — Server
|
||||
returns specs based on user/context
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Static Files</strong> — Pre-built
|
||||
specs for known UI patterns
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Learn about{" "}
|
||||
<Link href="/docs/catalog" className="text-foreground hover:underline">
|
||||
catalogs
|
||||
</Link>{" "}
|
||||
— the vocabulary of components available in your specs.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
export const metadata = { title: "Streaming" }
|
||||
|
||||
# Streaming
|
||||
|
||||
Progressively render UI as AI generates it.
|
||||
|
||||
## SpecStream Format
|
||||
|
||||
json-render uses **SpecStream**, a JSONL-based streaming format where each line is a JSON patch operation that progressively builds your spec:
|
||||
|
||||
```json
|
||||
{"op":"add","path":"/root","value":"root"}
|
||||
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
|
||||
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
|
||||
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}
|
||||
```
|
||||
|
||||
## useUIStream Hook
|
||||
|
||||
The hook handles parsing and state management:
|
||||
|
||||
```tsx
|
||||
import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
clear, // Function to reset spec and error
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onComplete: (spec) => {}, // Optional: called when streaming completes
|
||||
onError: (error) => {}, // Optional: called when an error occurs
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Patch Operations (RFC 6902)
|
||||
|
||||
SpecStream uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations:
|
||||
|
||||
- `add` — Add a value at a path (creates or replaces for objects, inserts for arrays)
|
||||
- `remove` — Remove the value at a path
|
||||
- `replace` — Replace an existing value at a path
|
||||
- `move` — Move a value from one path to another (requires `from`)
|
||||
- `copy` — Copy a value from one path to another (requires `from`)
|
||||
- `test` — Assert that a value at a path equals the given value
|
||||
|
||||
## Path Format
|
||||
|
||||
Paths use a key-based format for elements:
|
||||
|
||||
```bash
|
||||
/root -> Root element
|
||||
/root/children -> Children of root
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/children -> Children of card-1
|
||||
```
|
||||
|
||||
## Server-Side Setup
|
||||
|
||||
Ensure your API route streams properly:
|
||||
|
||||
```typescript
|
||||
import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: catalog.prompt(),
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Return as a streaming response
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
## Progressive Rendering
|
||||
|
||||
The Renderer automatically updates as the spec changes:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isStreaming && <LoadingIndicator />}
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Aborting Streams
|
||||
|
||||
Calling `send` again automatically aborts the previous request. Use `clear` to reset the spec and error state:
|
||||
|
||||
```tsx
|
||||
function App() {
|
||||
const { isStreaming, send, clear } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => send('Create dashboard')}>
|
||||
Generate
|
||||
</button>
|
||||
<button onClick={clear}>Reset</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Low-Level SpecStream API
|
||||
|
||||
For custom streaming implementations, use the SpecStream compiler directly:
|
||||
|
||||
```typescript
|
||||
import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const { result, newPatches } = compiler.push(value);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}
|
||||
```
|
||||
|
||||
### One-Shot Compilation
|
||||
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
|
||||
```typescript
|
||||
import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = `{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"add","path":"/root/props","value":{"title":"Hello"}}`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
// { root: { type: "Card", props: { title: "Hello" } } }
|
||||
```
|
||||
@@ -1,175 +0,0 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Streaming | json-render",
|
||||
};
|
||||
|
||||
export default function StreamingPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Streaming</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Progressively render UI as AI generates it.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream Format</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
|
||||
format where each line is a JSON patch operation that progressively
|
||||
builds your spec:
|
||||
</p>
|
||||
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
|
||||
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
|
||||
{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The hook handles parsing and state management:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
spec, // Current UI spec state
|
||||
isStreaming, // True while streaming
|
||||
error, // Any error that occurred
|
||||
send, // Function to start generation
|
||||
abort, // Function to cancel streaming
|
||||
} = useUIStream({
|
||||
api: '/api/generate',
|
||||
onChunk: (chunk) => {}, // Optional: called for each chunk
|
||||
onFinish: (spec) => {}, // Optional: called when complete
|
||||
});
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Supported operations:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">set</code> — Set the value at a path
|
||||
(creates if needed)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">add</code> — Add to an array at a
|
||||
path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">replace</code> — Replace value at a
|
||||
path
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">remove</code> — Remove value at a
|
||||
path
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Paths use a key-based format for elements:
|
||||
</p>
|
||||
<Code lang="bash">{`/root -> Root element
|
||||
/root/children -> Children of root
|
||||
/elements/card-1 -> Element with key "card-1"
|
||||
/elements/card-1/children -> Children of card-1`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Ensure your API route streams properly:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { streamText } from 'ai';
|
||||
import { catalog } from '@/lib/catalog';
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: catalog.prompt(),
|
||||
prompt,
|
||||
});
|
||||
|
||||
// Return as a streaming response
|
||||
return result.toTextStreamResponse();
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Progressive Rendering
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
The Renderer automatically updates as the spec changes:
|
||||
</p>
|
||||
<Code lang="tsx">{`function App() {
|
||||
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isStreaming && <LoadingIndicator />}
|
||||
<Renderer spec={spec} registry={registry} loading={isStreaming} />
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
|
||||
<Code lang="tsx">{`function App() {
|
||||
const { isStreaming, send, abort } = useUIStream({
|
||||
api: '/api/generate',
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => send('Create dashboard')}>
|
||||
Generate
|
||||
</button>
|
||||
{isStreaming && (
|
||||
<button onClick={abort}>Cancel</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Low-Level SpecStream API
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
For custom streaming implementations, use the SpecStream compiler
|
||||
directly:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
|
||||
|
||||
// Create a compiler for your spec type
|
||||
const compiler = createSpecStreamCompiler<MySpec>();
|
||||
|
||||
// Process streaming chunks from AI
|
||||
async function processStream(reader: ReadableStreamDefaultReader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const { result, newPatches } = compiler.push(value);
|
||||
|
||||
if (newPatches.length > 0) {
|
||||
// Update UI with partial result
|
||||
setSpec(result);
|
||||
}
|
||||
}
|
||||
|
||||
// Get final compiled result
|
||||
return compiler.getResult();
|
||||
}`}</Code>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
For non-streaming scenarios, compile entire SpecStream at once:
|
||||
</p>
|
||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||
|
||||
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
|
||||
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
|
||||
|
||||
const spec = compileSpecStream<MySpec>(jsonl);
|
||||
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
export const metadata = { title: "Validation" }
|
||||
|
||||
# Validation
|
||||
|
||||
Validate form inputs with built-in and custom functions.
|
||||
|
||||
## Built-in Validators
|
||||
|
||||
json-render includes common validation functions:
|
||||
|
||||
- `required` — Value must be non-empty
|
||||
- `email` — Valid email format
|
||||
- `minLength` — Minimum string length
|
||||
- `maxLength` — Maximum string length
|
||||
- `pattern` — Match a regex pattern
|
||||
- `min` — Minimum numeric value
|
||||
- `max` — Maximum numeric value
|
||||
|
||||
## Using Validation in JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Email",
|
||||
"valuePath": "/form/email",
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Email is required" },
|
||||
{ "fn": "email", "message": "Invalid email format" }
|
||||
],
|
||||
"validateOn": "blur"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Validation with Parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Password",
|
||||
"valuePath": "/form/password",
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Password is required" },
|
||||
{
|
||||
"fn": "minLength",
|
||||
"args": { "length": 8 },
|
||||
"message": "Password must be at least 8 characters"
|
||||
},
|
||||
{
|
||||
"fn": "pattern",
|
||||
"args": { "pattern": "[A-Z]" },
|
||||
"message": "Must contain at least one uppercase letter"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Validation Functions
|
||||
|
||||
Define custom validators in your catalog:
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/react';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { /* ... */ },
|
||||
functions: {
|
||||
isValidPhone: {
|
||||
description: 'Validates phone number format',
|
||||
},
|
||||
isUniqueEmail: {
|
||||
description: 'Checks if email is not already registered',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Then implement them in your ValidationProvider:
|
||||
|
||||
```tsx
|
||||
import { ValidationProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
const customValidators = {
|
||||
isValidPhone: (value) => {
|
||||
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
||||
return phoneRegex.test(value);
|
||||
},
|
||||
isUniqueEmail: async (value) => {
|
||||
const response = await fetch(`/api/check-email?email=${value}`);
|
||||
const { available } = await response.json();
|
||||
return available;
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ValidationProvider functions={customValidators}>
|
||||
{/* Your UI */}
|
||||
</ValidationProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
|
||||
```tsx
|
||||
import { useFieldValidation } from '@json-render/react';
|
||||
|
||||
function TextField({ props }) {
|
||||
const { value, setValue, errors, validate } = useFieldValidation(
|
||||
props.valuePath,
|
||||
props.checks
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label>{props.label}</label>
|
||||
<input
|
||||
value={value || ''}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => validate()}
|
||||
/>
|
||||
{errors.map((error, i) => (
|
||||
<p key={i} className="text-red-500 text-sm">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Timing
|
||||
|
||||
Control when validation runs with `validateOn`:
|
||||
|
||||
- `change` — Validate on every input change
|
||||
- `blur` — Validate when field loses focus
|
||||
- `submit` — Validate only on form submission
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [AI SDK integration](/docs/ai-sdk).
|
||||
@@ -1,189 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Validation | json-render",
|
||||
};
|
||||
|
||||
export default function ValidationPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Validation</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Validate form inputs with built-in and custom functions.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Validators</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
json-render includes common validation functions:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
|
||||
<li>
|
||||
<code className="text-foreground">required</code> — Value must be
|
||||
non-empty
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">email</code> — Valid email format
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">minLength</code> — Minimum string
|
||||
length
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">maxLength</code> — Maximum string
|
||||
length
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">pattern</code> — Match a regex
|
||||
pattern
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">min</code> — Minimum numeric value
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">max</code> — Maximum numeric value
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Using Validation in JSON
|
||||
</h2>
|
||||
<Code lang="json">{`{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Email",
|
||||
"valuePath": "/form/email",
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Email is required" },
|
||||
{ "fn": "email", "message": "Invalid email format" }
|
||||
],
|
||||
"validateOn": "blur"
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Validation with Parameters
|
||||
</h2>
|
||||
<Code lang="json">{`{
|
||||
"type": "TextField",
|
||||
"props": {
|
||||
"label": "Password",
|
||||
"valuePath": "/form/password",
|
||||
"checks": [
|
||||
{ "fn": "required", "message": "Password is required" },
|
||||
{
|
||||
"fn": "minLength",
|
||||
"args": { "length": 8 },
|
||||
"message": "Password must be at least 8 characters"
|
||||
},
|
||||
{
|
||||
"fn": "pattern",
|
||||
"args": { "pattern": "[A-Z]" },
|
||||
"message": "Must contain at least one uppercase letter"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Custom Validation Functions
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Define custom validators in your catalog:
|
||||
</p>
|
||||
<Code lang="typescript">{`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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
export const metadata = { title: "Visibility" }
|
||||
|
||||
# Visibility
|
||||
|
||||
Conditionally show or hide components based on data, auth, or logic.
|
||||
|
||||
## VisibilityProvider
|
||||
|
||||
Wrap your app with VisibilityProvider to enable conditional rendering:
|
||||
|
||||
```tsx
|
||||
import { VisibilityProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<StateProvider initialState={data}>
|
||||
<VisibilityProvider>
|
||||
{/* Components can now use visibility conditions */}
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Path-Based Visibility
|
||||
|
||||
Show/hide based on data values:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "path": "/form/hasErrors" }
|
||||
}
|
||||
|
||||
// Visible when /form/hasErrors is truthy
|
||||
```
|
||||
|
||||
## Auth-Based Visibility
|
||||
|
||||
Show/hide based on authentication state:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "auth": "signedIn" }
|
||||
}
|
||||
|
||||
// Options: "signedIn", "signedOut", "admin", etc.
|
||||
```
|
||||
|
||||
## Logic Expressions
|
||||
|
||||
Combine conditions with logic operators:
|
||||
|
||||
```json
|
||||
// AND - all conditions must be true
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/isValid" },
|
||||
{ "path": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// OR - any condition must be true
|
||||
{
|
||||
"type": "HelpText",
|
||||
"visible": {
|
||||
"or": [
|
||||
{ "path": "/user/isNew" },
|
||||
{ "path": "/settings/showHelp" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// NOT - invert a condition
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": {
|
||||
"not": { "path": "/user/hasSeenWelcome" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison Operators
|
||||
|
||||
```json
|
||||
// Equal
|
||||
{
|
||||
"visible": {
|
||||
"eq": [{ "path": "/user/role" }, "admin"]
|
||||
}
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": {
|
||||
"gt": [{ "path": "/cart/total" }, 100]
|
||||
}
|
||||
}
|
||||
|
||||
// Available: eq, ne, gt, gte, lt, lte
|
||||
```
|
||||
|
||||
## Complex Example
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "auth": "signedIn" },
|
||||
{ "eq": [{ "path": "/user/role" }, "support"] },
|
||||
{ "gt": [{ "path": "/order/amount" }, 0] },
|
||||
{ "not": { "path": "/order/isRefunded" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using in Components
|
||||
|
||||
```tsx
|
||||
import { useIsVisible } from '@json-render/react';
|
||||
|
||||
// The Renderer handles visibility automatically, but you can also use the hook
|
||||
function ConditionalContent({ condition, children }) {
|
||||
const isVisible = useIsVisible(condition);
|
||||
|
||||
if (!isVisible) return null;
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
```
|
||||
|
||||
## Next
|
||||
|
||||
Learn about [form validation](/docs/validation).
|
||||
@@ -1,148 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Visibility | json-render",
|
||||
};
|
||||
|
||||
export default function VisibilityPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Visibility</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Conditionally show or hide components based on data, auth, or logic.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">VisibilityProvider</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Wrap your app with VisibilityProvider to enable conditional rendering:
|
||||
</p>
|
||||
<Code lang="tsx">{`import { VisibilityProvider } from '@json-render/react';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<DataProvider initialData={data}>
|
||||
<VisibilityProvider>
|
||||
{/* Components can now use visibility conditions */}
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
);
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Path-Based Visibility
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Show/hide based on data values:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "Alert",
|
||||
"props": { "message": "Form has errors" },
|
||||
"visible": { "path": "/form/hasErrors" }
|
||||
}
|
||||
|
||||
// Visible when /form/hasErrors is truthy`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||
Auth-Based Visibility
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Show/hide based on authentication state:
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"type": "AdminPanel",
|
||||
"visible": { "auth": "signedIn" }
|
||||
}
|
||||
|
||||
// Options: "signedIn", "signedOut", "admin", etc.`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Logic Expressions</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Combine conditions with logic operators:
|
||||
</p>
|
||||
<Code lang="json">{`// AND - all conditions must be true
|
||||
{
|
||||
"type": "SubmitButton",
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "path": "/form/isValid" },
|
||||
{ "path": "/form/hasChanges" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// OR - any condition must be true
|
||||
{
|
||||
"type": "HelpText",
|
||||
"visible": {
|
||||
"or": [
|
||||
{ "path": "/user/isNew" },
|
||||
{ "path": "/settings/showHelp" }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// NOT - invert a condition
|
||||
{
|
||||
"type": "WelcomeBanner",
|
||||
"visible": {
|
||||
"not": { "path": "/user/hasSeenWelcome" }
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Comparison Operators</h2>
|
||||
<Code lang="json">{`// Equal
|
||||
{
|
||||
"visible": {
|
||||
"eq": [{ "path": "/user/role" }, "admin"]
|
||||
}
|
||||
}
|
||||
|
||||
// Greater than
|
||||
{
|
||||
"visible": {
|
||||
"gt": [{ "path": "/cart/total" }, 100]
|
||||
}
|
||||
}
|
||||
|
||||
// Available: eq, ne, gt, gte, lt, lte`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Complex Example</h2>
|
||||
<Code lang="json">{`{
|
||||
"type": "RefundButton",
|
||||
"props": { "label": "Process Refund" },
|
||||
"visible": {
|
||||
"and": [
|
||||
{ "auth": "signedIn" },
|
||||
{ "eq": [{ "path": "/user/role" }, "support"] },
|
||||
{ "gt": [{ "path": "/order/amount" }, 0] },
|
||||
{ "not": { "path": "/order/isRefunded" } }
|
||||
]
|
||||
}
|
||||
}`}</Code>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
|
||||
<Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -9,12 +9,15 @@ export default function Home() {
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
|
||||
The framework for User-Generated Interfaces
|
||||
</p>
|
||||
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
AI → json-render → UI
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
Define a component catalog. Users prompt. AI outputs JSON constrained
|
||||
to your catalog. Your components render it.
|
||||
Dynamic, personalized UIs per user without sacrificing reliability.
|
||||
Predefined components and actions for safe, predictable output.
|
||||
</p>
|
||||
|
||||
<Demo />
|
||||
@@ -65,10 +68,10 @@ export default function Home() {
|
||||
<div className="text-xs text-muted-foreground font-mono mb-3">
|
||||
02
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">Users Prompt</h3>
|
||||
<h3 className="text-lg font-semibold mb-2">Users Generate</h3>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
End users describe what they want. AI generates JSON constrained
|
||||
to your catalog.
|
||||
to your catalog. Every interface is unique to the user.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -127,15 +130,16 @@ export const catalog = createCatalog({
|
||||
Constrained output that your components render natively.
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"key": "dashboard",
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": "Revenue Dashboard",
|
||||
"description": null
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"key": "revenue",
|
||||
"root": "dashboard",
|
||||
"elements": {
|
||||
"dashboard": {
|
||||
"type": "Card",
|
||||
"props": {
|
||||
"title": "Revenue Dashboard"
|
||||
},
|
||||
"children": ["revenue"]
|
||||
},
|
||||
"revenue": {
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
@@ -143,7 +147,7 @@ export const catalog = createCatalog({
|
||||
"format": "currency"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}</Code>
|
||||
</div>
|
||||
</div>
|
||||
@@ -170,13 +174,11 @@ export const catalog = createCatalog({
|
||||
"root": "card",
|
||||
"elements": {
|
||||
"card": {
|
||||
"key": "card",
|
||||
"type": "Card",
|
||||
"props": { "title": "Revenue" },
|
||||
"children": ["metric", "chart"]
|
||||
},
|
||||
"metric": {
|
||||
"key": "metric",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
@@ -185,10 +187,9 @@ export const catalog = createCatalog({
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
"key": "chart",
|
||||
"type": "Chart",
|
||||
"props": {
|
||||
"dataPath": "analytics/salesByRegion"
|
||||
"statePath": "analytics/salesByRegion"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +225,7 @@ export default function Page() {
|
||||
valuePath="analytics/revenue"
|
||||
format="currency"
|
||||
/>
|
||||
<Chart data={data} dataPath="analytics/salesByRegion" />
|
||||
<Chart data={data} statePath="analytics/salesByRegion" />
|
||||
</Card>
|
||||
);
|
||||
}`}</Code>
|
||||
@@ -246,6 +247,10 @@ export default function Page() {
|
||||
<h2 className="text-2xl font-semibold mb-12 text-center">Features</h2>
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{[
|
||||
{
|
||||
title: "User-Generated Interfaces",
|
||||
desc: "Dynamic, personalized UIs per user powered by Generative UI",
|
||||
},
|
||||
{
|
||||
title: "Guardrails",
|
||||
desc: "AI can only use components you define in the catalog",
|
||||
@@ -255,20 +260,16 @@ export default function Page() {
|
||||
desc: "Progressive rendering as JSON streams from the model",
|
||||
},
|
||||
{
|
||||
title: "Code Export",
|
||||
desc: "Export as standalone React code with no runtime dependencies",
|
||||
title: "React & React Native",
|
||||
desc: "Render on web and mobile from the same catalog and spec format",
|
||||
},
|
||||
{
|
||||
title: "Data Binding",
|
||||
desc: "Two-way binding with JSON Pointer paths",
|
||||
desc: "Two-way state binding with dynamic prop expressions",
|
||||
},
|
||||
{
|
||||
title: "Actions",
|
||||
desc: "Named actions handled by your application",
|
||||
},
|
||||
{
|
||||
title: "Visibility",
|
||||
desc: "Conditional show/hide based on data or auth",
|
||||
title: "Code Export",
|
||||
desc: "Export as standalone React code with no runtime dependencies",
|
||||
},
|
||||
].map((feature) => (
|
||||
<div key={feature.title}>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { convertToModelMessages, stepCountIs, streamText } from "ai";
|
||||
import type { ModelMessage, UIMessage } from "ai";
|
||||
import { createBashTool } from "bash-tool";
|
||||
import { headers } from "next/headers";
|
||||
import { allDocsPages } from "@/lib/docs-navigation";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render, a library for AI-generated UI with guardrails.
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
|
||||
|
||||
When answering questions:
|
||||
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
|
||||
- Use the readFile tool to read specific documentation pages
|
||||
- Always base your answers on the actual documentation content
|
||||
- Be concise and accurate
|
||||
- If the docs don't cover a topic, say so honestly
|
||||
- Do NOT include source references or file paths in your response`;
|
||||
|
||||
async function loadDocsFiles(): Promise<Record<string, string>> {
|
||||
const files: Record<string, string> = {};
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
allDocsPages.map(async (page) => {
|
||||
const slug =
|
||||
page.href === "/docs" ? "" : page.href.replace(/^\/docs\/?/, "");
|
||||
const filePath = slug
|
||||
? join(
|
||||
process.cwd(),
|
||||
"app",
|
||||
"(main)",
|
||||
"docs",
|
||||
...slug.split("/"),
|
||||
"page.mdx",
|
||||
)
|
||||
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");
|
||||
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const md = mdxToCleanMarkdown(raw);
|
||||
const fileName = slug ? `/docs/${slug}.md` : "/docs/index.md";
|
||||
return { fileName, md };
|
||||
}),
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fulfilled") {
|
||||
files[result.value.fileName] = result.value.md;
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
|
||||
if (messages.length === 0) return messages;
|
||||
return messages.map((message, index) => {
|
||||
if (index === messages.length - 1) {
|
||||
return {
|
||||
...message,
|
||||
providerOptions: {
|
||||
...message.providerOptions,
|
||||
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { messages }: { messages: UIMessage[] } = await req.json();
|
||||
|
||||
const docsFiles = await loadDocsFiles();
|
||||
const { tools } = await createBashTool({ files: docsFiles });
|
||||
|
||||
const result = streamText({
|
||||
model: DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: await convertToModelMessages(messages),
|
||||
stopWhen: stepCountIs(5),
|
||||
tools,
|
||||
prepareStep: ({ messages: stepMessages }) => ({
|
||||
messages: addCacheControl(stepMessages),
|
||||
}),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { mdxToCleanMarkdown } from "@/lib/mdx-to-markdown";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const docPath = searchParams.get("path");
|
||||
|
||||
if (!docPath) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing ?path= parameter" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize path: only allow docs paths, no traversal
|
||||
const normalized = docPath
|
||||
.replace(/^\//, "")
|
||||
.replace(/\.\./g, "")
|
||||
.replace(/[^a-zA-Z0-9/-]/g, "");
|
||||
|
||||
if (!normalized.startsWith("docs")) {
|
||||
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Map URL path to file path
|
||||
// /docs -> /app/(main)/docs/page.mdx
|
||||
// /docs/installation -> /app/(main)/docs/installation/page.mdx
|
||||
const slug = normalized === "docs" ? "" : normalized.replace(/^docs\/?/, "");
|
||||
const filePath = slug
|
||||
? join(
|
||||
process.cwd(),
|
||||
"app",
|
||||
"(main)",
|
||||
"docs",
|
||||
...slug.split("/"),
|
||||
"page.mdx",
|
||||
)
|
||||
: join(process.cwd(), "app", "(main)", "docs", "page.mdx");
|
||||
|
||||
try {
|
||||
const raw = await readFile(filePath, "utf-8");
|
||||
const markdown = mdxToCleanMarkdown(raw);
|
||||
|
||||
return new NextResponse(markdown, {
|
||||
headers: {
|
||||
"Content-Type": "text/markdown; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Page not found" }, { status: 404 });
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,19 @@
|
||||
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/catalog";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
||||
customRules: [
|
||||
"For forms: Card should be the root element, not wrapped in a centering Stack",
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
|
||||
"NEVER use page background colors (bg-gray-50) - container has its own background",
|
||||
"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.",
|
||||
],
|
||||
});
|
||||
|
||||
@@ -44,30 +48,12 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const { prompt, context } = await req.json();
|
||||
const previousSpec = context?.previousSpec;
|
||||
|
||||
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
||||
|
||||
// Build the user prompt, including previous tree for iteration
|
||||
let userPrompt = sanitizedPrompt;
|
||||
if (
|
||||
previousSpec &&
|
||||
previousSpec.root &&
|
||||
Object.keys(previousSpec.elements || {}).length > 0
|
||||
) {
|
||||
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
|
||||
${JSON.stringify(previousSpec, null, 2)}
|
||||
|
||||
USER REQUEST: ${sanitizedPrompt}
|
||||
|
||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
||||
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
|
||||
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
|
||||
- To add children: update the parent element with new children array
|
||||
|
||||
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||
}
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: context?.previousSpec,
|
||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
@@ -76,5 +62,33 @@ DO NOT output patches for elements that don't need to change. Only output what's
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
// Stream the text, then append token usage metadata at the end
|
||||
const encoder = new TextEncoder();
|
||||
const textStream = result.textStream;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
for await (const chunk of textStream) {
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
// Append usage metadata after stream completes
|
||||
try {
|
||||
const usage = await result.usage;
|
||||
const meta = JSON.stringify({
|
||||
__meta: "usage",
|
||||
promptTokens: usage.inputTokens,
|
||||
completionTokens: usage.outputTokens,
|
||||
totalTokens: usage.totalTokens,
|
||||
});
|
||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||
} catch {
|
||||
// Usage not available -- skip silently
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
|
||||
+12
-7
@@ -4,6 +4,7 @@ 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",
|
||||
@@ -17,15 +18,19 @@ const geistMono = localFont({
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://json-render.dev"),
|
||||
title: {
|
||||
default: "json-render | AI-generated UI with guardrails",
|
||||
default: `json-render | ${PAGE_TITLES[""]}`,
|
||||
template: "%s | json-render",
|
||||
},
|
||||
description:
|
||||
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
keywords: [
|
||||
"json-render",
|
||||
"UGI",
|
||||
"User-Generated Interfaces",
|
||||
"AI UI generation",
|
||||
"generative UI",
|
||||
"React components",
|
||||
"React Native",
|
||||
"guardrails",
|
||||
"structured output",
|
||||
"dashboard builder",
|
||||
@@ -37,23 +42,23 @@ export const metadata: Metadata = {
|
||||
locale: "en_US",
|
||||
url: "https://json-render.dev",
|
||||
siteName: "json-render",
|
||||
title: "json-render | AI-generated UI with guardrails",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
description:
|
||||
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: [
|
||||
{
|
||||
url: "/og",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "json-render - AI-generated UI with guardrails",
|
||||
alt: "json-render - The framework for User-Generated Interfaces",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "json-render | AI-generated UI with guardrails",
|
||||
title: "json-render | The framework for User-Generated Interfaces",
|
||||
description:
|
||||
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
|
||||
"The framework for User-Generated Interfaces (UGI). Let users generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||
images: ["/og"],
|
||||
creator: "@verabornnot",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { Header } from "@/components/header";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<Header />
|
||||
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
|
||||
<h1 className="text-6xl font-bold tracking-tight">404</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
This page could not be found.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-2 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPageTitle, renderOgImage } from "../og-image";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ slug: string[] }> },
|
||||
) {
|
||||
const { slug } = await params;
|
||||
const title = getPageTitle(slug.join("/"));
|
||||
|
||||
if (!title) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return renderOgImage(title);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{title.split("\n").map((line, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 72,
|
||||
fontFamily: "Geist",
|
||||
fontWeight: 400,
|
||||
color: "white",
|
||||
letterSpacing: "-0.02em",
|
||||
textAlign: "center",
|
||||
lineHeight: 1.2,
|
||||
}}
|
||||
>
|
||||
{line}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
{
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{
|
||||
name: "Geist",
|
||||
data: geistRegular.buffer as ArrayBuffer,
|
||||
style: "normal",
|
||||
weight: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,6 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { getPageTitle, renderOgImage } from "./og-image";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
export async function GET() {
|
||||
const title = getPageTitle("")!;
|
||||
return renderOgImage(title);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Playground } from "@/components/playground";
|
||||
|
||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
||||
|
||||
export const metadata = {
|
||||
title: "Playground | json-render",
|
||||
title: PAGE_TITLES["playground"],
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export function CopyPageButton() {
|
||||
const pathname = usePathname();
|
||||
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
|
||||
|
||||
const handleCopy = async () => {
|
||||
setState("loading");
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch markdown");
|
||||
}
|
||||
const markdown = await response.text();
|
||||
await navigator.clipboard.writeText(markdown);
|
||||
setState("copied");
|
||||
setTimeout(() => setState("idle"), 2000);
|
||||
} catch {
|
||||
setState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
disabled={state === "loading"}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
|
||||
aria-label="Copy page as Markdown"
|
||||
>
|
||||
{state === "copied" ? (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
Copy Page
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+301
-49
@@ -14,7 +14,8 @@ import { toast } from "sonner";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { PlaygroundRenderer } from "@/lib/renderer";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
|
||||
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
|
||||
|
||||
@@ -29,130 +30,115 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
stream: '{"op":"set","path":"/root","value":"card"}',
|
||||
stream: '{"op":"add","path":"/root","value":"card"}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
key: "message",
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
|
||||
},
|
||||
{
|
||||
tree: {
|
||||
root: "card",
|
||||
elements: {
|
||||
card: {
|
||||
key: "card",
|
||||
type: "Card",
|
||||
props: { title: "Contact Us", maxWidth: "md" },
|
||||
children: ["name", "email", "message", "submit"],
|
||||
},
|
||||
name: {
|
||||
key: "name",
|
||||
type: "Input",
|
||||
props: { label: "Name", name: "name" },
|
||||
},
|
||||
email: {
|
||||
key: "email",
|
||||
type: "Input",
|
||||
props: { label: "Email", name: "email" },
|
||||
},
|
||||
message: {
|
||||
key: "message",
|
||||
type: "Textarea",
|
||||
props: { label: "Message", name: "message" },
|
||||
},
|
||||
submit: {
|
||||
key: "submit",
|
||||
type: "Button",
|
||||
props: { label: "Send Message", variant: "primary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
stream:
|
||||
'{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
|
||||
},
|
||||
];
|
||||
|
||||
type Mode = "simulation" | "interactive";
|
||||
type Phase = "typing" | "streaming" | "complete";
|
||||
type Tab = "stream" | "json";
|
||||
type Tab = "stream" | "json" | "nested" | "catalog";
|
||||
type RenderView = "dynamic" | "static";
|
||||
|
||||
interface DemoProps {
|
||||
@@ -160,6 +146,51 @@ 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",
|
||||
@@ -194,6 +225,92 @@ 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(() => {
|
||||
@@ -213,6 +330,7 @@ export function Demo({
|
||||
isStreaming,
|
||||
send,
|
||||
clear,
|
||||
rawLines: apiRawLines,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => {
|
||||
@@ -285,25 +403,12 @@ export function Demo({
|
||||
return () => clearInterval(interval);
|
||||
}, [mode, phase]);
|
||||
|
||||
// Track stream lines from real API
|
||||
// Track stream lines from real API (use raw JSONL patch lines)
|
||||
useEffect(() => {
|
||||
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;
|
||||
});
|
||||
}
|
||||
if (mode === "interactive" && apiRawLines.length > 0) {
|
||||
setStreamLines(apiRawLines);
|
||||
}
|
||||
}, [mode, apiSpec, streamLines]);
|
||||
}, [mode, apiRawLines]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!userPrompt.trim() || isStreaming) return;
|
||||
@@ -315,6 +420,11 @@ 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) {
|
||||
@@ -915,7 +1025,13 @@ Open [http://localhost:3000](http://localhost:3000) to view.
|
||||
setMode("interactive");
|
||||
setPhase("complete");
|
||||
setUserPrompt(prompt);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
setTimeout(() => {
|
||||
const el = inputRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.setSelectionRange(prompt.length, prompt.length);
|
||||
}
|
||||
}, 0);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -1044,7 +1160,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", "stream"] as const).map((tab) => (
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -1061,14 +1177,20 @@ 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]"}`}
|
||||
>
|
||||
<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>
|
||||
{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={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
|
||||
>
|
||||
@@ -1104,6 +1226,136 @@ 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>
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
const STORAGE_KEY = "docs-chat-messages";
|
||||
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
||||
|
||||
export function DocsChat() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [input, setInput] = useState("");
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const { messages, sendMessage, status, setMessages } = useChat({ transport });
|
||||
|
||||
const isLoading = status === "streaming" || status === "submitted";
|
||||
|
||||
// Restore messages from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
restoredRef.current = true;
|
||||
try {
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
setMessages(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}, [setMessages]);
|
||||
|
||||
// Save completed messages to sessionStorage
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) return;
|
||||
if (isLoading) return;
|
||||
if (messages.length === 0) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
||||
} catch {
|
||||
// ignore quota errors
|
||||
}
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Auto-open when new messages arrive
|
||||
const prevMessageCount = useRef(0);
|
||||
useEffect(() => {
|
||||
if (messages.length > prevMessageCount.current) {
|
||||
setOpen(true);
|
||||
}
|
||||
prevMessageCount.current = messages.length;
|
||||
}, [messages.length]);
|
||||
|
||||
// Scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
// Close message area when clicking outside
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current &&
|
||||
!containerRef.current.contains(e.target as Node)
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim() || isLoading) return;
|
||||
sendMessage({ text: input });
|
||||
setInput("");
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setMessages([]);
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const getTextFromParts = (
|
||||
parts: (typeof messages)[number]["parts"],
|
||||
): string => {
|
||||
return parts
|
||||
.filter(
|
||||
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
|
||||
)
|
||||
.map((p) => p.text)
|
||||
.join("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
|
||||
>
|
||||
{/* Messages panel */}
|
||||
{open && messages.length > 0 && (
|
||||
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
json-render Docs
|
||||
</span>
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Clear conversation"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4 overflow-y-auto">
|
||||
{messages.map((message) => {
|
||||
const text = getTextFromParts(message.parts);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div key={message.id}>
|
||||
{message.role === "assistant" ? (
|
||||
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
|
||||
<Streamdown>{text}</Streamdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input bar */}
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
e.target.style.height = "auto";
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
placeholder="Ask about the docs..."
|
||||
rows={1}
|
||||
onFocus={() => {
|
||||
if (messages.length > 0) setOpen(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !input.trim()}
|
||||
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>10k</span>
|
||||
<span>10.1k</span>
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
+700
-102
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useUIStream } from "@json-render/react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useUIStream, type TokenUsage } from "@json-render/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||
import { toast } from "sonner";
|
||||
@@ -14,17 +15,72 @@ import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import { PlaygroundRenderer } from "@/lib/renderer";
|
||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||
|
||||
type Tab = "json" | "stream";
|
||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobilePane = "chat" | "code" | "preview";
|
||||
type MobileView =
|
||||
| "json"
|
||||
| "nested"
|
||||
| "stream"
|
||||
| "catalog"
|
||||
| "preview"
|
||||
| "generated-code";
|
||||
|
||||
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 = [
|
||||
@@ -40,11 +96,15 @@ 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 [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track the currently generating version ID
|
||||
@@ -56,6 +116,8 @@ export function Playground() {
|
||||
const {
|
||||
spec: apiSpec,
|
||||
isStreaming,
|
||||
usage: streamUsage,
|
||||
rawLines: streamRawLines,
|
||||
send,
|
||||
clear,
|
||||
} = useUIStream({
|
||||
@@ -93,6 +155,11 @@ 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 &&
|
||||
@@ -107,24 +174,6 @@ 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 (
|
||||
@@ -137,13 +186,19 @@ export function Playground() {
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === completedVersionId
|
||||
? { ...v, tree: apiSpec, status: "complete" as const }
|
||||
? {
|
||||
...v,
|
||||
tree: apiSpec,
|
||||
status: "complete" as const,
|
||||
usage: streamUsage,
|
||||
rawLines: streamRawLines,
|
||||
}
|
||||
: v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
}, [isStreaming, apiSpec]);
|
||||
}, [isStreaming, apiSpec, streamUsage, streamRawLines]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!inputValue.trim() || isStreaming) return;
|
||||
@@ -154,13 +209,14 @@ 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 });
|
||||
@@ -180,6 +236,11 @@ 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";
|
||||
@@ -262,17 +323,23 @@ ${jsx}
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => {
|
||||
setInputValue(prompt);
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(
|
||||
prompt.length,
|
||||
prompt.length,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
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);
|
||||
}
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
@@ -306,6 +373,13 @@ ${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>
|
||||
))
|
||||
)}
|
||||
@@ -330,6 +404,7 @@ ${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 ? (
|
||||
@@ -337,7 +412,6 @@ ${jsx}
|
||||
onClick={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
setStreamLines([]);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
@@ -390,11 +464,104 @@ ${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", "stream"] as const).map((tab) => (
|
||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
@@ -408,16 +575,132 @@ ${jsx}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<CopyButton
|
||||
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
{activeTab !== "catalog" && (
|
||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "stream" ? (
|
||||
streamLines.length > 0 ? (
|
||||
{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 ? (
|
||||
<CodeBlock
|
||||
code={streamLines.join("\n")}
|
||||
code={currentRawLines.join("\n")}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
@@ -427,6 +710,8 @@ ${jsx}
|
||||
{isStreaming ? "streaming..." : "// waiting for generation"}
|
||||
</div>
|
||||
)
|
||||
) : activeTab === "nested" ? (
|
||||
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
||||
) : (
|
||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||
)}
|
||||
@@ -465,7 +750,11 @@ ${jsx}
|
||||
{renderView === "preview" ? (
|
||||
currentTree && currentTree.root ? (
|
||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||
<PlaygroundRenderer spec={currentTree} loading={isStreaming} />
|
||||
<PlaygroundRenderer
|
||||
spec={currentTree}
|
||||
data={currentTree.state}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
||||
@@ -507,68 +796,377 @@ ${jsx}
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Single pane with bottom tabs */}
|
||||
{/* Mobile: toolbar + content + prompt input */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
{/* Panes - all in DOM, visibility controlled */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
|
||||
{/* 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"
|
||||
>
|
||||
{chatPane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
|
||||
>
|
||||
{codePane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
|
||||
>
|
||||
{previewPane}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab bar */}
|
||||
<div className="border-t border-border flex shrink-0">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "chat",
|
||||
label: "Chat",
|
||||
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "Preview",
|
||||
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label, icon }) => (
|
||||
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={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"
|
||||
key={tab}
|
||||
onClick={() => setMobileView(tab)}
|
||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||
mobileView === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-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>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { Accordion as AccordionPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
|
||||
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
|
||||
}
|
||||
|
||||
function AccordionItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
|
||||
return (
|
||||
<AccordionPrimitive.Content
|
||||
data-slot="accordion-content"
|
||||
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
|
||||
{...props}
|
||||
>
|
||||
<div className={cn("pt-0 pb-4", className)}>{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -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,9 +22,11 @@ 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",
|
||||
},
|
||||
@@ -46,7 +48,7 @@ function Button({
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const Comp = asChild ? Slot.Root : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,158 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,135 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,257 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
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,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
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 };
|
||||
@@ -0,0 +1,63 @@
|
||||
"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,15 +1,29 @@
|
||||
"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 { resolvedTheme } = useTheme();
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={resolvedTheme as ToasterProps["theme"]}
|
||||
theme={theme 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)",
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"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,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,47 @@
|
||||
"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 };
|
||||
@@ -0,0 +1,57 @@
|
||||
"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 };
|
||||
@@ -1,266 +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 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.",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
"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>;
|
||||
}
|
||||
@@ -39,6 +39,11 @@ export const docsNavigation: NavSection[] = [
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/dashboard",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "React Native",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Remotion",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
@@ -69,6 +74,7 @@ export const docsNavigation: NavSection[] = [
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 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)
|
||||
"": "The framework for\nUser-Generated Interfaces",
|
||||
|
||||
// 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/react-native": "@json-render/react-native 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;
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
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
@@ -0,0 +1,73 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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,5 +1,9 @@
|
||||
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 [
|
||||
{
|
||||
@@ -11,4 +15,6 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const withMDX = createMDX({});
|
||||
|
||||
export default withMDX(nextConfig);
|
||||
|
||||
@@ -13,9 +13,14 @@
|
||||
},
|
||||
"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",
|
||||
@@ -24,8 +29,11 @@
|
||||
"@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",
|
||||
@@ -35,13 +43,16 @@
|
||||
"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,17 +11,15 @@ const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, context } = await req.json();
|
||||
|
||||
let fullPrompt = prompt;
|
||||
|
||||
// Add data context
|
||||
if (context?.data) {
|
||||
fullPrompt += `\n\nAVAILABLE DATA:\n${JSON.stringify(context.data, null, 2)}`;
|
||||
}
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
state: context?.data,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: fullPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function Widget({
|
||||
dragHandleProps,
|
||||
}: WidgetProps): React.ReactElement {
|
||||
const [prompt, setPrompt] = useState(initialPrompt || "");
|
||||
const [data, setData] = useState<Record<string, unknown>>({});
|
||||
const [state, setState] = 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 dataRef = useRef(data);
|
||||
const stateRef = useRef(state);
|
||||
const editInputRef = useRef<HTMLInputElement>(null);
|
||||
const promptInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep dataRef in sync
|
||||
// Keep stateRef in sync
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
// 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, { data });
|
||||
await send(p, { state });
|
||||
onGenerated?.();
|
||||
},
|
||||
[prompt, send, data, onGenerated],
|
||||
[prompt, send, state, onGenerated],
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const handleClear = useCallback(() => {
|
||||
clear();
|
||||
setPrompt("");
|
||||
setData({});
|
||||
setState({});
|
||||
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, { data, previousSpec: existingSpec });
|
||||
await send(editPrompt, { state, previousSpec: existingSpec });
|
||||
onGenerated?.();
|
||||
}
|
||||
}, [editPrompt, send, data, spec, initialSpec, onGenerated]);
|
||||
}, [editPrompt, send, state, spec, initialSpec, onGenerated]);
|
||||
|
||||
const handleEditKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
@@ -196,8 +196,8 @@ export function Widget({
|
||||
onDeleted?.();
|
||||
}, [widgetId, onDeleted]);
|
||||
|
||||
const handleDataChange = useCallback((path: string, value: unknown) => {
|
||||
setData((prev) => {
|
||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
||||
setState((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, setData);
|
||||
executeAction(action, params, setState);
|
||||
});
|
||||
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?.dataPath) {
|
||||
const dataPath = element.props.dataPath as string;
|
||||
if (dataComponents.includes(element.type) && element.props?.statePath) {
|
||||
const statePath = element.props.statePath as string;
|
||||
// Extract resource name (e.g., "customers" from "customers.data")
|
||||
const resource = dataPath.split(".")[0];
|
||||
const resource = statePath.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>,
|
||||
setData,
|
||||
setState,
|
||||
);
|
||||
actionsRun.add(resource);
|
||||
break;
|
||||
@@ -387,9 +387,9 @@ export function Widget({
|
||||
) : (
|
||||
<DashboardRenderer
|
||||
spec={currentSpec}
|
||||
data={data}
|
||||
setData={setData}
|
||||
onDataChange={handleDataChange}
|
||||
state={state}
|
||||
setState={setState}
|
||||
onStateChange={handleStateChange}
|
||||
loading={isStreaming}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -277,7 +277,7 @@ export const dashboardCatalog = defineCatalog(schema, {
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
dataPath: z.string(),
|
||||
statePath: 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(),
|
||||
dataPath: z.string(),
|
||||
statePath: 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. 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.",
|
||||
"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.",
|
||||
},
|
||||
|
||||
LineChart: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
dataPath: z.string(),
|
||||
statePath: 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. 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.",
|
||||
"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.",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { toast } from "sonner";
|
||||
import { findFormValue, getByPath } from "@json-render/core";
|
||||
import { useData, defineRegistry } from "@json-render/react";
|
||||
import { useStateStore, defineRegistry } from "@json-render/react";
|
||||
import {
|
||||
Bar,
|
||||
BarChart as RechartsBarChart,
|
||||
@@ -132,29 +132,24 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</AccordionItem>
|
||||
),
|
||||
|
||||
Button: ({ props, onAction, loading }) => (
|
||||
Button: ({ props, emit, loading }) => (
|
||||
<Button
|
||||
variant={props.variant ?? "default"}
|
||||
disabled={loading || (props.disabled ?? false)}
|
||||
onClick={() =>
|
||||
onAction?.({
|
||||
name: props.action,
|
||||
params: props.actionParams ?? undefined,
|
||||
})
|
||||
}
|
||||
onClick={() => emit?.("press")}
|
||||
>
|
||||
{loading ? "..." : props.label}
|
||||
</Button>
|
||||
),
|
||||
|
||||
Input: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const { state, set } = useStateStore();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Input
|
||||
type={props.type ?? "text"}
|
||||
value={(getByPath(data, props.valuePath) as string) ?? ""}
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
/>
|
||||
@@ -162,14 +157,11 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
);
|
||||
},
|
||||
|
||||
Form: ({ props, children, onAction }) => (
|
||||
Form: ({ children, emit }) => (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onAction?.({
|
||||
name: props.submitAction,
|
||||
params: props.submitActionParams ?? undefined,
|
||||
});
|
||||
emit?.("submit");
|
||||
}}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
@@ -202,9 +194,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Checkbox: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(data, props.valuePath) as boolean) ??
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
return (
|
||||
@@ -255,26 +247,14 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</Drawer>
|
||||
),
|
||||
|
||||
DropdownMenu: ({ props, onAction }) => (
|
||||
DropdownMenu: ({ props }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">{props.trigger}</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{props.items.map((item, i) => (
|
||||
<DropdownMenuItem
|
||||
key={i}
|
||||
onClick={() =>
|
||||
item.action
|
||||
? onAction?.({
|
||||
name: item.action,
|
||||
params: item.actionParams ?? undefined,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem key={i}>{item.label}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -324,9 +304,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
RadioGroup: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const { state, set } = useStateStore();
|
||||
const value =
|
||||
(getByPath(data, props.valuePath) as string) ??
|
||||
(getByPath(state, props.valuePath) as string) ??
|
||||
props.defaultValue ??
|
||||
"";
|
||||
return (
|
||||
@@ -345,8 +325,8 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
Select: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const value = (getByPath(data, props.valuePath) as string) ?? "";
|
||||
const { state, set } = useStateStore();
|
||||
const value = (getByPath(state, props.valuePath) as string) ?? "";
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
|
||||
<SelectTrigger>
|
||||
@@ -380,9 +360,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
Switch: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const { state, set } = useStateStore();
|
||||
const checked =
|
||||
(getByPath(data, props.valuePath) as boolean) ??
|
||||
(getByPath(state, props.valuePath) as boolean) ??
|
||||
props.defaultChecked ??
|
||||
false;
|
||||
return (
|
||||
@@ -417,12 +397,12 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
),
|
||||
|
||||
Textarea: ({ props }) => {
|
||||
const { data, set } = useData();
|
||||
const { state, set } = useStateStore();
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{props.label ? <Label>{props.label}</Label> : null}
|
||||
<Textarea
|
||||
value={(getByPath(data, props.valuePath) as string) ?? ""}
|
||||
value={(getByPath(state, props.valuePath) as string) ?? ""}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
onChange={(e) => set(props.valuePath, e.target.value)}
|
||||
@@ -449,10 +429,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
</p>
|
||||
),
|
||||
|
||||
Table: ({ props, onAction }) => {
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
Table: ({ props }) => {
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
|
||||
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -502,12 +482,6 @@ 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>
|
||||
@@ -523,9 +497,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
BarChart: ({ props }) => {
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -590,9 +564,9 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
LineChart: ({ props }) => {
|
||||
const { data } = useData();
|
||||
const path = props.dataPath.replace(/\./g, "/");
|
||||
const rawData = getByPath(data, path);
|
||||
const { state } = useStateStore();
|
||||
const path = props.statePath.replace(/\./g, "/");
|
||||
const rawData = getByPath(state, path);
|
||||
|
||||
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
|
||||
? rawData
|
||||
@@ -660,7 +634,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
},
|
||||
|
||||
actions: {
|
||||
viewCustomers: async (params, setData) => {
|
||||
viewCustomers: async (params, setState) => {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.limit) queryParams.set("limit", String(params.limit));
|
||||
if (params?.sort) queryParams.set("sort", String(params.sort));
|
||||
@@ -668,25 +642,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();
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
refreshCustomers: async (params, setData) => {
|
||||
refreshCustomers: async (params, setState) => {
|
||||
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();
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
},
|
||||
|
||||
createCustomer: async (params, setData, data) => {
|
||||
const name = findFormValue("name", params, data) as string;
|
||||
createCustomer: async (params, setState, state) => {
|
||||
const name = findFormValue("name", params, state) as string;
|
||||
const email =
|
||||
(findFormValue("email", params, data) as string) ||
|
||||
(findFormValue("email", params, state) as string) ||
|
||||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
|
||||
const phone = findFormValue("phone", params, data) as
|
||||
const phone = findFormValue("phone", params, state) as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
@@ -706,7 +680,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();
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
toast.error(customer.error || "Failed to create customer");
|
||||
}
|
||||
@@ -716,10 +690,10 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomer: async (params, setData, data) => {
|
||||
deleteCustomer: async (params, setState, state) => {
|
||||
const customerId =
|
||||
findFormValue("customerId", params, data) ||
|
||||
findFormValue("id", params, data);
|
||||
findFormValue("customerId", params, state) ||
|
||||
findFormValue("id", params, state);
|
||||
if (!customerId) {
|
||||
toast.error("Customer ID required");
|
||||
return;
|
||||
@@ -732,7 +706,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Customer deleted");
|
||||
const listRes = await fetch("/api/v1/customers");
|
||||
const customers = await listRes.json();
|
||||
setData((prev) => ({ ...prev, customers }));
|
||||
setState((prev) => ({ ...prev, customers }));
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.error || "Failed to delete customer");
|
||||
@@ -743,22 +717,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
viewInvoices: async (params, setData) => {
|
||||
viewInvoices: async (params, setState) => {
|
||||
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();
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
refreshInvoices: async (_params, setData) => {
|
||||
refreshInvoices: async (_params, setState) => {
|
||||
const res = await fetch("/api/v1/invoices");
|
||||
const invoices = await res.json();
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
},
|
||||
|
||||
createInvoice: async (params, setData) => {
|
||||
createInvoice: async (params, setState) => {
|
||||
if (!params?.customerId || !params?.dueDate) {
|
||||
toast.error("Customer ID and due date required");
|
||||
return;
|
||||
@@ -774,7 +748,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Invoice created");
|
||||
const listRes = await fetch("/api/v1/invoices");
|
||||
const invoices = await listRes.json();
|
||||
setData((prev) => ({ ...prev, invoices }));
|
||||
setState((prev) => ({ ...prev, invoices }));
|
||||
} else {
|
||||
toast.error(invoice.error || "Failed to create invoice");
|
||||
}
|
||||
@@ -817,22 +791,22 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
}
|
||||
},
|
||||
|
||||
viewExpenses: async (params, setData) => {
|
||||
viewExpenses: async (params, setState) => {
|
||||
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();
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
refreshExpenses: async (_params, setData) => {
|
||||
refreshExpenses: async (_params, setState) => {
|
||||
const res = await fetch("/api/v1/expenses");
|
||||
const expenses = await res.json();
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
},
|
||||
|
||||
createExpense: async (params, setData) => {
|
||||
createExpense: async (params, setState) => {
|
||||
if (
|
||||
!params?.vendor ||
|
||||
!params?.category ||
|
||||
@@ -852,7 +826,7 @@ export const { registry, handlers, executeAction } = defineRegistry(
|
||||
toast.success("Expense created");
|
||||
const listRes = await fetch("/api/v1/expenses");
|
||||
const expenses = await listRes.json();
|
||||
setData((prev) => ({ ...prev, expenses }));
|
||||
setState((prev) => ({ ...prev, expenses }));
|
||||
} else {
|
||||
toast.error(expense.error || "Failed to create expense");
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Renderer,
|
||||
type ComponentRenderer,
|
||||
type Spec,
|
||||
DataProvider,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ActionProvider,
|
||||
} from "@json-render/react";
|
||||
@@ -16,15 +16,15 @@ import { registry, Fallback, handlers as createHandlers } from "./registry";
|
||||
// DashboardRenderer
|
||||
// =============================================================================
|
||||
|
||||
type SetData = (
|
||||
type SetState = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
interface DashboardRendererProps {
|
||||
spec: Spec | null;
|
||||
data?: Record<string, unknown>;
|
||||
setData?: SetData;
|
||||
onDataChange?: (path: string, value: unknown) => void;
|
||||
state?: Record<string, unknown>;
|
||||
setState?: SetState;
|
||||
onStateChange?: (path: string, value: unknown) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,24 +35,24 @@ const fallback: ComponentRenderer = ({ element }) => (
|
||||
|
||||
export function DashboardRenderer({
|
||||
spec,
|
||||
data = {},
|
||||
setData,
|
||||
onDataChange,
|
||||
state = {},
|
||||
setState,
|
||||
onStateChange,
|
||||
loading,
|
||||
}: DashboardRendererProps): ReactNode {
|
||||
// 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;
|
||||
// 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;
|
||||
|
||||
// Create ActionProvider-compatible handlers using getters so they
|
||||
// always read the latest data/setData from refs
|
||||
// always read the latest state/setState from refs
|
||||
const actionHandlers = useMemo(
|
||||
() =>
|
||||
createHandlers(
|
||||
() => setDataRef.current,
|
||||
() => dataRef.current,
|
||||
() => setStateRef.current,
|
||||
() => stateRef.current,
|
||||
),
|
||||
[],
|
||||
);
|
||||
@@ -60,7 +60,7 @@ export function DashboardRenderer({
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<DataProvider initialData={data} onDataChange={onDataChange}>
|
||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
||||
<VisibilityProvider>
|
||||
<ActionProvider handlers={actionHandlers}>
|
||||
<Renderer
|
||||
@@ -71,6 +71,6 @@ export function DashboardRenderer({
|
||||
/>
|
||||
</ActionProvider>
|
||||
</VisibilityProvider>
|
||||
</DataProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user