mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e467d33a2 | ||
|
|
00ef6fd851 | ||
|
|
5b357909ba | ||
|
|
b527f8d3c8 | ||
|
|
7df7864f5b | ||
|
|
06235798e1 | ||
|
|
e323d263d0 | ||
|
|
75ab55f178 | ||
|
|
43756783fe | ||
|
|
af9dd04085 |
@@ -39,3 +39,4 @@ yarn-error.log*
|
||||
|
||||
# opensrc - source code for packages
|
||||
opensrc/
|
||||
.env*.local
|
||||
|
||||
+12
-2
@@ -1,4 +1,14 @@
|
||||
# AI Gateway API Key (required for /api/generate endpoint)
|
||||
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
|
||||
# Vercel AI Gateway
|
||||
# Automatically authenticated when deployed on Vercel
|
||||
# For local development, get your key from https://vercel.com/ai-gateway
|
||||
AI_GATEWAY_API_KEY=
|
||||
|
||||
# AI Model Configuration
|
||||
# Override the default model used for UI generation
|
||||
# Default: anthropic/claude-haiku-4.5
|
||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
|
||||
# Vercel KV (Rate Limiting)
|
||||
# Automatically populated when you add Vercel KV to your project
|
||||
KV_REST_API_URL=
|
||||
KV_REST_API_TOKEN=
|
||||
|
||||
@@ -35,3 +35,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.env*.local
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function POST(req: Request) {
|
||||
: '';
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-opus-4.5',
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: systemPrompt + contextPrompt,
|
||||
prompt,
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "@json-render/codegen API | json-render",
|
||||
};
|
||||
|
||||
export default function CodegenApiPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Utilities for generating code from UI trees.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Walk the UI tree depth-first.
|
||||
</p>
|
||||
<Code lang="typescript">{`function traverseTree(
|
||||
tree: UITree,
|
||||
visitor: TreeVisitor,
|
||||
startKey?: string
|
||||
): void
|
||||
|
||||
interface TreeVisitor {
|
||||
(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 tree.
|
||||
</p>
|
||||
<Code lang="typescript">{`function collectUsedComponents(tree: UITree): Set<string>
|
||||
|
||||
// Example
|
||||
const components = collectUsedComponents(tree);
|
||||
// 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(tree: UITree): Set<string>
|
||||
|
||||
// Example
|
||||
const paths = collectDataPaths(tree);
|
||||
// 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 tree.
|
||||
</p>
|
||||
<Code lang="typescript">{`function collectActions(tree: UITree): Set<string>
|
||||
|
||||
// Example
|
||||
const actions = collectActions(tree);
|
||||
// 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 tree */
|
||||
generate(tree: UITree): 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,170 @@
|
||||
import { Code } from "@/components/code";
|
||||
|
||||
export const metadata = {
|
||||
title: "Code Export | json-render",
|
||||
};
|
||||
|
||||
export default function CodeExportPage() {
|
||||
return (
|
||||
<article>
|
||||
<h1 className="text-3xl font-bold mb-4">Code Export</h1>
|
||||
<p className="text-muted-foreground mb-8">
|
||||
Export generated UI as standalone code for your framework.
|
||||
</p>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
While json-render is designed for dynamic rendering, you can export
|
||||
generated UI as static code. The code generation is intentionally
|
||||
project-specific so you have full control over:
|
||||
</p>
|
||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
|
||||
<li>Component templates (standalone, no json-render dependencies)</li>
|
||||
<li>Package.json and project structure</li>
|
||||
<li>Framework-specific patterns (Next.js, Remix, etc.)</li>
|
||||
<li>How data is passed to components</li>
|
||||
</ul>
|
||||
|
||||
<h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Code export is split into two parts:
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-semibold mt-8 mb-4">
|
||||
1. @json-render/codegen (utilities)
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Framework-agnostic utilities for building code generators:
|
||||
</p>
|
||||
<Code lang="typescript">{`import {
|
||||
traverseTree, // Walk the UI tree
|
||||
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(tree: UITree): GeneratedFile[] {
|
||||
const components = collectUsedComponents(tree);
|
||||
|
||||
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">traverseTree</h3>
|
||||
<Code lang="typescript">{`import { traverseTree } from '@json-render/codegen';
|
||||
|
||||
traverseTree(tree, (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(tree);
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const navigation = [
|
||||
items: [
|
||||
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Code Export", href: "/docs/code-export" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -33,6 +34,7 @@ const navigation = [
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
+1
-1
@@ -100,7 +100,7 @@ export async function POST(req: Request) {
|
||||
const systemPrompt = generateCatalogPrompt(catalog);
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-opus-4.5',
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: systemPrompt,
|
||||
prompt,
|
||||
});
|
||||
@@ -79,7 +79,7 @@ function App() {
|
||||
const { prompt } = await req.json();
|
||||
|
||||
const result = streamText({
|
||||
model: 'anthropic/claude-opus-4.5',
|
||||
model: 'anthropic/claude-haiku-4.5',
|
||||
system: generateCatalogPrompt(catalog),
|
||||
prompt,
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
|
||||
export default function MainLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,11 +10,11 @@ export default function Home() {
|
||||
{/* Hero */}
|
||||
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
|
||||
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
|
||||
Predictable. Guardrailed. Fast.
|
||||
AI → json-render → UI
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
|
||||
Let users generate dashboards, widgets, apps, and data visualizations
|
||||
from prompts — safely constrained to components you define.
|
||||
Define a component catalog. Users prompt. AI outputs JSON constrained
|
||||
to your catalog. Your components render it.
|
||||
</p>
|
||||
|
||||
<Demo />
|
||||
@@ -150,6 +150,96 @@ export const catalog = createCatalog({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Code Export */}
|
||||
<section className="border-t border-border">
|
||||
<div className="max-w-5xl mx-auto px-6 py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-2xl font-semibold mb-4">Export as Code</h2>
|
||||
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||
Export generated UI as standalone React components. No runtime
|
||||
dependencies required.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid lg:grid-cols-2 gap-12">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold mb-4">Generated UI Tree</h3>
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
AI generates a JSON structure from the user's prompt.
|
||||
</p>
|
||||
<Code lang="json">{`{
|
||||
"root": "card",
|
||||
"elements": {
|
||||
"card": {
|
||||
"key": "card",
|
||||
"type": "Card",
|
||||
"props": { "title": "Revenue" },
|
||||
"children": ["metric", "chart"]
|
||||
},
|
||||
"metric": {
|
||||
"key": "metric",
|
||||
"type": "Metric",
|
||||
"props": {
|
||||
"label": "Total Revenue",
|
||||
"valuePath": "analytics/revenue",
|
||||
"format": "currency"
|
||||
}
|
||||
},
|
||||
"chart": {
|
||||
"key": "chart",
|
||||
"type": "Chart",
|
||||
"props": {
|
||||
"dataPath": "analytics/salesByRegion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`}</Code>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
Exported React Code
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-6 text-sm">
|
||||
Export as a standalone Next.js project with all components.
|
||||
</p>
|
||||
<Code lang="tsx">{`"use client";
|
||||
|
||||
import { Card, Metric, Chart } from "@/components/ui";
|
||||
|
||||
const data = {
|
||||
analytics: {
|
||||
revenue: 125000,
|
||||
salesByRegion: [
|
||||
{ label: "US", value: 45000 },
|
||||
{ label: "EU", value: 35000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<Card data={data} title="Revenue">
|
||||
<Metric
|
||||
data={data}
|
||||
label="Total Revenue"
|
||||
valuePath="analytics/revenue"
|
||||
format="currency"
|
||||
/>
|
||||
<Chart data={data} dataPath="analytics/salesByRegion" />
|
||||
</Card>
|
||||
);
|
||||
}`}</Code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The export includes{" "}
|
||||
<code className="text-foreground">package.json</code>, component
|
||||
files, styles, and everything needed to run independently.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="border-t border-border">
|
||||
<div className="max-w-5xl mx-auto px-6 py-24">
|
||||
@@ -164,6 +254,10 @@ export const catalog = createCatalog({
|
||||
title: "Streaming",
|
||||
desc: "Progressive rendering as JSON streams from the model",
|
||||
},
|
||||
{
|
||||
title: "Code Export",
|
||||
desc: "Export as standalone React code with no runtime dependencies",
|
||||
},
|
||||
{
|
||||
title: "Data Binding",
|
||||
desc: "Two-way binding with JSON Pointer paths",
|
||||
@@ -176,10 +270,6 @@ export const catalog = createCatalog({
|
||||
title: "Visibility",
|
||||
desc: "Conditional show/hide based on data or auth",
|
||||
},
|
||||
{
|
||||
title: "Validation",
|
||||
desc: "Built-in and custom validation functions",
|
||||
},
|
||||
].map((feature) => (
|
||||
<div key={feature.title}>
|
||||
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
||||
@@ -1,93 +1,79 @@
|
||||
import { streamText } from "ai";
|
||||
import { headers } from "next/headers";
|
||||
import { generateSystemPrompt } from "@json-render/core";
|
||||
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||
import { playgroundCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
|
||||
const SYSTEM_PROMPT = generateSystemPrompt(playgroundCatalog, {
|
||||
customRules: [
|
||||
"For forms: Card should be the root element, not wrapped in a centering Stack",
|
||||
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
|
||||
"NEVER use page background colors (bg-gray-50) - container has its own background",
|
||||
],
|
||||
});
|
||||
|
||||
AVAILABLE COMPONENTS (22):
|
||||
|
||||
Layout:
|
||||
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
|
||||
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
|
||||
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
|
||||
- Divider: {} - Horizontal separator line
|
||||
|
||||
Form Inputs:
|
||||
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
|
||||
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
|
||||
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
|
||||
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
|
||||
- Radio: { label: string, name: string, options: string[] } - Radio button group
|
||||
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
|
||||
|
||||
Actions:
|
||||
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
|
||||
- Link: { label: string, href: string } - Anchor link
|
||||
|
||||
Typography:
|
||||
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
|
||||
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
|
||||
|
||||
Data Display:
|
||||
- Image: { src: string, alt: string, width?: number, height?: number } - Image
|
||||
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
|
||||
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
|
||||
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
|
||||
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
|
||||
- Rating: { value: number, max?: number, label?: string } - Star rating display
|
||||
|
||||
Charts:
|
||||
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
|
||||
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
|
||||
|
||||
OUTPUT FORMAT (JSONL):
|
||||
{"op":"set","path":"/root","value":"element-key"}
|
||||
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
|
||||
|
||||
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
|
||||
|
||||
RULES:
|
||||
1. First line sets /root to root element key
|
||||
2. Add elements with /elements/{key}
|
||||
3. Children array contains string keys, not objects
|
||||
4. Parent first, then children
|
||||
5. Each element needs: key, type, props
|
||||
6. Use className for custom Tailwind styling when needed
|
||||
|
||||
FORBIDDEN CLASSES (NEVER USE):
|
||||
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
|
||||
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
|
||||
|
||||
MOBILE-FIRST RESPONSIVE:
|
||||
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
|
||||
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
|
||||
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
|
||||
- Horizontal stacks that may overflow should use className:["flex-wrap"]
|
||||
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
|
||||
|
||||
EXAMPLE (Blog with responsive grid):
|
||||
{"op":"set","path":"/root","value":"page"}
|
||||
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
|
||||
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
|
||||
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
|
||||
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
|
||||
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
|
||||
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
|
||||
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
|
||||
|
||||
Generate JSONL:`;
|
||||
|
||||
const MAX_PROMPT_LENGTH = 140;
|
||||
const MAX_PROMPT_LENGTH = 500;
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt } = await req.json();
|
||||
// Get client IP for rate limiting
|
||||
const headersList = await headers();
|
||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||
|
||||
// Check rate limits (minute and daily)
|
||||
const [minuteResult, dailyResult] = await Promise.all([
|
||||
minuteRateLimit.limit(ip),
|
||||
dailyRateLimit.limit(ip),
|
||||
]);
|
||||
|
||||
if (!minuteResult.success || !dailyResult.success) {
|
||||
const isMinuteLimit = !minuteResult.success;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Rate limit exceeded",
|
||||
message: isMinuteLimit
|
||||
? "Too many requests. Please wait a moment before trying again."
|
||||
: "Daily limit reached. Please try again tomorrow.",
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, context } = await req.json();
|
||||
const previousTree = context?.previousTree;
|
||||
|
||||
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
|
||||
|
||||
// Build the user prompt, including previous tree for iteration
|
||||
let userPrompt = sanitizedPrompt;
|
||||
if (
|
||||
previousTree &&
|
||||
previousTree.root &&
|
||||
Object.keys(previousTree.elements || {}).length > 0
|
||||
) {
|
||||
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
|
||||
${JSON.stringify(previousTree, null, 2)}
|
||||
|
||||
USER REQUEST: ${sanitizedPrompt}
|
||||
|
||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
||||
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
|
||||
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
|
||||
- To add children: update the parent element with new children array
|
||||
|
||||
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: sanitizedPrompt,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
:root {
|
||||
--radius: 0.5rem;
|
||||
--ds-gray-500: oklch(0.836 0 0);
|
||||
/* Monochrome light theme */
|
||||
--background: oklch(1.0 0 0);
|
||||
--foreground: oklch(0.1 0 0);
|
||||
@@ -28,6 +29,7 @@
|
||||
}
|
||||
|
||||
.dark {
|
||||
--ds-gray-500: oklch(0.39 0 0);
|
||||
/* Monochrome dark theme */
|
||||
--background: oklch(0.0 0 0);
|
||||
--foreground: oklch(0.98 0 0);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
import { Header } from "@/components/header";
|
||||
import { Footer } from "@/components/footer";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||
@@ -76,13 +74,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
||||
<ThemeProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<main className="flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
<ThemeProvider>{children}</ThemeProvider>
|
||||
<Analytics />
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function PlaygroundLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
|
||||
}
|
||||
@@ -1,72 +1,9 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Playground } from "@/components/playground";
|
||||
|
||||
export const metadata = {
|
||||
title: "Playground | json-render",
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-6 py-16">
|
||||
<h1 className="text-3xl font-bold mb-4">Playground</h1>
|
||||
<p className="text-muted-foreground mb-12">
|
||||
Try json-render with a live example.
|
||||
</p>
|
||||
|
||||
<div className="space-y-12">
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Clone the repository and run the example dashboard.
|
||||
</p>
|
||||
<pre className="text-sm mb-4">
|
||||
<code>{`git clone https://github.com/vercel-labs/json-render
|
||||
cd json-render
|
||||
pnpm install
|
||||
pnpm dev`}</code>
|
||||
</pre>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Open <code>http://localhost:3001</code> for the example dashboard.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Try these prompts in the example dashboard:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
"Create a revenue dashboard with monthly metrics",
|
||||
"Build a user management panel with a table",
|
||||
"Design a settings form with text inputs",
|
||||
"Make a notification center with alerts",
|
||||
].map((prompt) => (
|
||||
<div
|
||||
key={prompt}
|
||||
className="p-3 border border-border rounded text-sm font-mono"
|
||||
>
|
||||
{prompt}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
A browser-based playground is coming soon.
|
||||
</p>
|
||||
<Button variant="outline" asChild>
|
||||
<a
|
||||
href="https://github.com/vercel-labs/json-render"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Star on GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <Playground />;
|
||||
}
|
||||
|
||||
@@ -159,9 +159,16 @@ if (typeof window !== "undefined") {
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
lang: "json" | "tsx" | "typescript";
|
||||
fillHeight?: boolean;
|
||||
hideCopyButton?: boolean;
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, lang }: CodeBlockProps) {
|
||||
export function CodeBlock({
|
||||
code,
|
||||
lang,
|
||||
fillHeight,
|
||||
hideCopyButton,
|
||||
}: CodeBlockProps) {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,19 +187,21 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
|
||||
}, [code, lang]);
|
||||
|
||||
if (!html) {
|
||||
return null;
|
||||
return fillHeight ? <div className="p-3" /> : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div className="sticky top-0 float-right z-10">
|
||||
<CopyButton
|
||||
text={code}
|
||||
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
<div className={`relative group ${fillHeight ? "p-3" : ""}`}>
|
||||
{!hideCopyButton && (
|
||||
<div className="float-right sticky top-3 z-10 ml-2">
|
||||
<CopyButton
|
||||
text={code}
|
||||
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
|
||||
className="text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[13px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[13px]!"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
+1155
-94
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ export function Badge({ element }: ComponentRenderProps) {
|
||||
<span
|
||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
|
||||
>
|
||||
{props.text as string}
|
||||
{(props.text ?? props.label) as string}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,17 @@ export function Grid({ element, children }: ComponentRenderProps) {
|
||||
const hasCustomCols = customClass.includes("grid-cols-");
|
||||
const cols = hasCustomCols
|
||||
? ""
|
||||
: props.columns === 4
|
||||
? "grid-cols-4"
|
||||
: props.columns === 3
|
||||
? "grid-cols-3"
|
||||
: props.columns === 2
|
||||
? "grid-cols-2"
|
||||
: "grid-cols-1";
|
||||
: 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-3" : props.gap === "sm" ? "gap-1" : "gap-2";
|
||||
|
||||
|
||||
@@ -7,12 +7,41 @@ export function Stack({ element, children }: ComponentRenderProps) {
|
||||
const { props } = element;
|
||||
const customClass = getCustomClass(props);
|
||||
const isHorizontal = props.direction === "horizontal";
|
||||
const stackGap =
|
||||
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
|
||||
|
||||
const gapClass =
|
||||
props.gap === "lg"
|
||||
? "gap-3"
|
||||
: props.gap === "md"
|
||||
? "gap-2"
|
||||
: props.gap === "sm"
|
||||
? "gap-1"
|
||||
: props.gap === "none"
|
||||
? "gap-0"
|
||||
: "gap-2";
|
||||
|
||||
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 items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
|
||||
className={`flex ${isHorizontal ? "flex-row flex-wrap" : "flex-col"} ${gapClass} ${alignClass} ${justifyClass} ${baseClass} ${customClass}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ export function Text({ element }: ComponentRenderProps) {
|
||||
|
||||
return (
|
||||
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
|
||||
{props.content as string}
|
||||
{(props.text ?? props.content) as string}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,60 @@ import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
|
||||
<div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-semibold hover:opacity-70 transition-opacity"
|
||||
>
|
||||
json-render
|
||||
</Link>
|
||||
<nav className="flex gap-4 items-center text-sm">
|
||||
<header className="sticky top-0 z-50 bg-background">
|
||||
<div className="flex h-14 items-center justify-between px-4 gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="https://vercel.com" title="Made with love by Vercel">
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="18"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="18"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 1L16 15H0L8 1Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</Link>
|
||||
<span className="text-(--ds-gray-500)">
|
||||
<svg
|
||||
data-testid="geist-icon"
|
||||
height="16"
|
||||
strokeLinejoin="round"
|
||||
viewBox="0 0 16 16"
|
||||
width="16"
|
||||
style={{ color: "currentcolor" }}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</span>
|
||||
<Link href="/">
|
||||
<span className="font-medium tracking-tight text-lg">
|
||||
json-render
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/playground"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<span className="sm:hidden">Play</span>
|
||||
<span className="hidden sm:inline">Playground</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Docs
|
||||
</Link>
|
||||
@@ -22,7 +64,7 @@ export function Header() {
|
||||
href="https://github.com/vercel-labs/json-render"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
|
||||
import type { UITree } from "@json-render/core";
|
||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { CodeBlock } from "./code-block";
|
||||
import { CopyButton } from "./copy-button";
|
||||
import { Toaster } from "./ui/sonner";
|
||||
import { Header } from "./header";
|
||||
import {
|
||||
demoRegistry,
|
||||
fallbackComponent,
|
||||
useInteractiveState,
|
||||
} from "./demo/index";
|
||||
|
||||
type Tab = "json" | "stream";
|
||||
type RenderView = "preview" | "code";
|
||||
type MobilePane = "chat" | "code" | "preview";
|
||||
|
||||
interface Version {
|
||||
id: string;
|
||||
prompt: string;
|
||||
tree: UITree | null;
|
||||
status: "generating" | "complete" | "error";
|
||||
}
|
||||
|
||||
const EXAMPLE_PROMPTS = [
|
||||
"Create a login form",
|
||||
"Build a pricing page",
|
||||
"Design a user profile card",
|
||||
"Make a contact form",
|
||||
];
|
||||
|
||||
export function Playground() {
|
||||
const [versions, setVersions] = useState<Version[]>([]);
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [streamLines, setStreamLines] = useState<string[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<Tab>("json");
|
||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Track the currently generating version ID
|
||||
const generatingVersionIdRef = useRef<string | null>(null);
|
||||
|
||||
// Track the current tree for use as previousTree in next generation
|
||||
const currentTreeRef = useRef<UITree | null>(null);
|
||||
|
||||
const {
|
||||
tree: apiTree,
|
||||
isStreaming,
|
||||
send,
|
||||
clear,
|
||||
} = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err: Error) => {
|
||||
console.error("Generation error:", err);
|
||||
toast.error(err.message || "Generation failed. Please try again.");
|
||||
// Mark the version as errored
|
||||
if (generatingVersionIdRef.current) {
|
||||
const erroredVersionId = generatingVersionIdRef.current;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === erroredVersionId ? { ...v, status: "error" as const } : v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
},
|
||||
} as Parameters<typeof useUIStream>[0]);
|
||||
|
||||
useInteractiveState();
|
||||
|
||||
// Get the selected version
|
||||
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
||||
|
||||
// Determine which tree to display:
|
||||
// - If streaming and selected version is the generating one, show apiTree
|
||||
// - Otherwise show the selected version's tree
|
||||
const isSelectedVersionGenerating =
|
||||
selectedVersionId === generatingVersionIdRef.current && isStreaming;
|
||||
const hasValidApiTree =
|
||||
apiTree && apiTree.root && Object.keys(apiTree.elements).length > 0;
|
||||
|
||||
const currentTree =
|
||||
isSelectedVersionGenerating && hasValidApiTree
|
||||
? apiTree
|
||||
: (selectedVersion?.tree ??
|
||||
(isSelectedVersionGenerating ? apiTree : null));
|
||||
|
||||
// Keep the ref updated with the current tree for use in handleSubmit
|
||||
if (
|
||||
currentTree &&
|
||||
currentTree.root &&
|
||||
Object.keys(currentTree.elements).length > 0
|
||||
) {
|
||||
currentTreeRef.current = currentTree;
|
||||
}
|
||||
|
||||
// Scroll to bottom when versions change
|
||||
useEffect(() => {
|
||||
versionsEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [versions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiTree) {
|
||||
const streamLine = JSON.stringify({ tree: apiTree });
|
||||
if (
|
||||
!streamLines.includes(streamLine) &&
|
||||
Object.keys(apiTree.elements).length > 0
|
||||
) {
|
||||
setStreamLines((prev) => {
|
||||
const lastLine = prev[prev.length - 1];
|
||||
if (lastLine !== streamLine) {
|
||||
return [...prev, streamLine];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [apiTree, streamLines]);
|
||||
|
||||
// Update version when streaming completes
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isStreaming &&
|
||||
apiTree &&
|
||||
apiTree.root &&
|
||||
generatingVersionIdRef.current
|
||||
) {
|
||||
const completedVersionId = generatingVersionIdRef.current;
|
||||
setVersions((prev) =>
|
||||
prev.map((v) =>
|
||||
v.id === completedVersionId
|
||||
? { ...v, tree: apiTree, status: "complete" as const }
|
||||
: v,
|
||||
),
|
||||
);
|
||||
generatingVersionIdRef.current = null;
|
||||
}
|
||||
}, [isStreaming, apiTree]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!inputValue.trim() || isStreaming) return;
|
||||
|
||||
const newVersionId = Date.now().toString();
|
||||
const newVersion: Version = {
|
||||
id: newVersionId,
|
||||
prompt: inputValue.trim(),
|
||||
tree: null,
|
||||
status: "generating",
|
||||
};
|
||||
|
||||
generatingVersionIdRef.current = newVersionId;
|
||||
setVersions((prev) => [...prev, newVersion]);
|
||||
setSelectedVersionId(newVersionId);
|
||||
setInputValue("");
|
||||
setStreamLines([]); // Reset stream lines for new generation
|
||||
|
||||
// Pass the current tree as context so the API can iterate on it
|
||||
await send(inputValue.trim(), { previousTree: currentTreeRef.current });
|
||||
}, [inputValue, isStreaming, send]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(
|
||||
window as unknown as { __demoAction?: (text: string) => void }
|
||||
).__demoAction = (text: string) => {
|
||||
toast(text);
|
||||
};
|
||||
return () => {
|
||||
delete (window as unknown as { __demoAction?: (text: string) => void })
|
||||
.__demoAction;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const jsonCode = currentTree
|
||||
? JSON.stringify(currentTree, null, 2)
|
||||
: "// waiting...";
|
||||
|
||||
const generatedCode = useMemo(() => {
|
||||
if (!currentTree || !currentTree.root) {
|
||||
return "// Generate a UI to see the code";
|
||||
}
|
||||
|
||||
const tree = currentTree;
|
||||
const components = collectUsedComponents(tree);
|
||||
|
||||
function generateJSX(key: string, indent: number): string {
|
||||
const element = tree.elements[key];
|
||||
if (!element) return "";
|
||||
|
||||
const spaces = " ".repeat(indent);
|
||||
const componentName = element.type;
|
||||
|
||||
const propsObj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(element.props)) {
|
||||
if (v !== null && v !== undefined) {
|
||||
propsObj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
const propsStr = serializeProps(propsObj);
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
|
||||
if (!hasChildren) {
|
||||
return propsStr
|
||||
? `${spaces}<${componentName} ${propsStr} />`
|
||||
: `${spaces}<${componentName} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
propsStr
|
||||
? `${spaces}<${componentName} ${propsStr}>`
|
||||
: `${spaces}<${componentName}>`,
|
||||
);
|
||||
|
||||
for (const childKey of element.children!) {
|
||||
lines.push(generateJSX(childKey, indent + 1));
|
||||
}
|
||||
|
||||
lines.push(`${spaces}</${componentName}>`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const jsx = generateJSX(tree.root, 2);
|
||||
const imports = Array.from(components).sort().join(", ");
|
||||
|
||||
return `"use client";
|
||||
|
||||
import { ${imports} } from "@/components/ui";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="min-h-screen p-8 flex items-center justify-center">
|
||||
${jsx}
|
||||
</div>
|
||||
);
|
||||
}`;
|
||||
}, [currentTree]);
|
||||
|
||||
// Chat pane content
|
||||
const chatPane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
versions
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 p-2 min-h-0 ${versions.length > 0 ? "overflow-y-auto space-y-1" : "flex"}`}
|
||||
>
|
||||
{versions.length === 0 ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Describe what you want to build, then iterate on it.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{EXAMPLE_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
onClick={() => {
|
||||
setInputValue(prompt);
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(
|
||||
prompt.length,
|
||||
prompt.length,
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/30 transition-colors"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
versions.map((version, index) => (
|
||||
<button
|
||||
key={version.id}
|
||||
onClick={() => setSelectedVersionId(version.id)}
|
||||
className={`w-full text-left px-3 py-2 rounded text-sm transition-colors ${
|
||||
selectedVersionId === version.id
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-muted-foreground/70 shrink-0">
|
||||
v{index + 1}
|
||||
</span>
|
||||
<span className="truncate flex-1">{version.prompt}</span>
|
||||
{version.status === "generating" && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 animate-pulse">
|
||||
...
|
||||
</span>
|
||||
)}
|
||||
{version.status === "error" && (
|
||||
<span className="text-xs text-red-500 shrink-0">failed</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div ref={versionsEndRef} />
|
||||
</div>
|
||||
<div
|
||||
className="border-t border-border p-3 cursor-text"
|
||||
onMouseDown={(e) => {
|
||||
// Focus textarea unless clicking a button or the textarea itself
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Describe changes..."
|
||||
className="w-full bg-background text-base sm:text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-2">
|
||||
{versions.length > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
setStreamLines([]);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={() => clear()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||
aria-label="Stop"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
>
|
||||
<rect x="6" y="6" width="12" height="12" />
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!inputValue.trim()}
|
||||
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||
aria-label="Send"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m12 5 7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Code pane content
|
||||
const codePane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(["json", "stream"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<CopyButton
|
||||
text={activeTab === "stream" ? streamLines.join("\n") : jsonCode}
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === "stream" ? (
|
||||
streamLines.length > 0 ? (
|
||||
<CodeBlock
|
||||
code={streamLines.join("\n")}
|
||||
lang="json"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||
{isStreaming ? "streaming..." : "// waiting for generation"}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Preview pane content
|
||||
const previewPane = (
|
||||
<div className="h-full flex flex-col border-t border-border">
|
||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||
{(
|
||||
[
|
||||
{ key: "preview", label: "preview" },
|
||||
{ key: "code", label: "code" },
|
||||
] as const
|
||||
).map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setRenderView(key)}
|
||||
className={`text-xs font-mono transition-colors ${
|
||||
renderView === key
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{renderView === "code" && (
|
||||
<CopyButton text={generatedCode} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{renderView === "preview" ? (
|
||||
currentTree && currentTree.root ? (
|
||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||
<JSONUIProvider
|
||||
registry={
|
||||
demoRegistry as Parameters<
|
||||
typeof JSONUIProvider
|
||||
>[0]["registry"]
|
||||
}
|
||||
>
|
||||
<Renderer
|
||||
tree={currentTree!}
|
||||
registry={
|
||||
demoRegistry as Parameters<typeof Renderer>[0]["registry"]
|
||||
}
|
||||
loading={isStreaming}
|
||||
fallback={
|
||||
fallbackComponent as Parameters<
|
||||
typeof Renderer
|
||||
>[0]["fallback"]
|
||||
}
|
||||
/>
|
||||
</JSONUIProvider>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
|
||||
{isStreaming
|
||||
? "generating..."
|
||||
: "// enter a prompt to generate UI"}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CodeBlock
|
||||
code={generatedCode}
|
||||
lang="tsx"
|
||||
fillHeight
|
||||
hideCopyButton
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Header />
|
||||
|
||||
{/* Desktop: 3-pane resizable layout */}
|
||||
<div className="hidden lg:flex flex-1 min-h-0">
|
||||
<ResizablePanelGroup className="flex-1">
|
||||
<ResizablePanel defaultSize={25} minSize={15}>
|
||||
{chatPane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={35} minSize={20}>
|
||||
{codePane}
|
||||
</ResizablePanel>
|
||||
<ResizableHandle />
|
||||
<ResizablePanel defaultSize={40} minSize={20}>
|
||||
{previewPane}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Mobile: Single pane with bottom tabs */}
|
||||
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||
{/* Panes - all in DOM, visibility controlled */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "chat" ? "" : "invisible"}`}
|
||||
>
|
||||
{chatPane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "code" ? "" : "invisible"}`}
|
||||
>
|
||||
{codePane}
|
||||
</div>
|
||||
<div
|
||||
className={`absolute inset-0 ${mobilePane === "preview" ? "" : "invisible"}`}
|
||||
>
|
||||
{previewPane}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom tab bar */}
|
||||
<div className="border-t border-border flex shrink-0">
|
||||
{(
|
||||
[
|
||||
{
|
||||
key: "chat",
|
||||
label: "Chat",
|
||||
icon: "M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z",
|
||||
},
|
||||
{
|
||||
key: "code",
|
||||
label: "Code",
|
||||
icon: "M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4",
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "Preview",
|
||||
icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z",
|
||||
},
|
||||
] as const
|
||||
).map(({ key, label, icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setMobilePane(key)}
|
||||
className={`flex-1 py-3 flex flex-col items-center gap-1 transition-colors ${
|
||||
mobilePane === key ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||
</svg>
|
||||
<span className="text-xs">{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Toaster position="bottom-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import { Group, Panel, Separator } from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Group>) {
|
||||
return (
|
||||
<Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: React.ComponentProps<typeof Panel>) {
|
||||
return <Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator> & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
@@ -0,0 +1,262 @@
|
||||
import { createCatalog } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Web playground component catalog
|
||||
*
|
||||
* This defines the components available for AI generation in the playground.
|
||||
*/
|
||||
export const playgroundCatalog = createCatalog({
|
||||
name: "playground",
|
||||
components: {
|
||||
// Layout Components
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "full"]).optional(),
|
||||
centered: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
},
|
||||
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).optional(),
|
||||
gap: z.enum(["none", "sm", "md", "lg"]).optional(),
|
||||
align: z.enum(["start", "center", "end", "stretch"]).optional(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
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),
|
||||
])
|
||||
.optional(),
|
||||
gap: z.enum(["sm", "md", "lg"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
hasChildren: true,
|
||||
description: "Grid layout (1-6 columns)",
|
||||
},
|
||||
|
||||
Divider: {
|
||||
props: z.object({
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Horizontal separator line",
|
||||
},
|
||||
|
||||
// Form Inputs
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).optional(),
|
||||
placeholder: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Text input field",
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
rows: z.number().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Multi-line text input",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Dropdown select input",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Checkbox input",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Radio button group",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Toggle switch input",
|
||||
},
|
||||
|
||||
// Actions
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).optional(),
|
||||
actionText: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description:
|
||||
"Clickable button. actionText is shown in toast on click (defaults to label)",
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Anchor link",
|
||||
},
|
||||
|
||||
// Typography
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z
|
||||
.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4)])
|
||||
.optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "caption", "muted"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
},
|
||||
|
||||
// Data Display
|
||||
Image: {
|
||||
props: z.object({
|
||||
alt: z.string(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Placeholder image (displays alt text in a styled box)",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().optional(),
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["default", "success", "warning", "danger"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string().optional(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
},
|
||||
|
||||
Rating: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().optional(),
|
||||
label: z.string().optional(),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Star rating display",
|
||||
},
|
||||
|
||||
// Charts
|
||||
BarGraph: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Vertical bar chart",
|
||||
},
|
||||
|
||||
LineGraph: {
|
||||
props: z.object({
|
||||
title: z.string().optional(),
|
||||
data: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
}),
|
||||
),
|
||||
className: z.array(z.string()).optional(),
|
||||
}),
|
||||
description: "Line chart with points",
|
||||
},
|
||||
},
|
||||
validation: "strict",
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Redis } from "@upstash/redis";
|
||||
|
||||
const redis = new Redis({
|
||||
url: process.env.KV_REST_API_URL!,
|
||||
token: process.env.KV_REST_API_TOKEN!,
|
||||
});
|
||||
|
||||
// 10 requests per minute (sliding window)
|
||||
export const minuteRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.slidingWindow(10, "1 m"),
|
||||
prefix: "ratelimit:minute",
|
||||
});
|
||||
|
||||
// 100 requests per day (fixed window)
|
||||
export const dailyRateLimit = new Ratelimit({
|
||||
redis,
|
||||
limiter: Ratelimit.fixedWindow(100, "1 d"),
|
||||
prefix: "ratelimit:daily",
|
||||
});
|
||||
@@ -5,7 +5,7 @@
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3000",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0",
|
||||
@@ -13,11 +13,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"@json-render/codegen": "workspace:*",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@upstash/ratelimit": "^2.0.8",
|
||||
"@upstash/redis": "^1.36.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"ai": "^6.0.33",
|
||||
@@ -28,6 +31,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"shiki": "^3.21.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
# AI Gateway API Key (required for /api/generate endpoint)
|
||||
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
|
||||
# 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 dashboard generation
|
||||
# Default: anthropic/claude-haiku-4.5
|
||||
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
|
||||
|
||||
@@ -1,62 +1,12 @@
|
||||
import { streamText } from "ai";
|
||||
import { componentList } from "@/lib/catalog";
|
||||
import { generateSystemPrompt } from "@json-render/core";
|
||||
import { dashboardCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 30;
|
||||
|
||||
const SYSTEM_PROMPT = `You are a dashboard widget generator that outputs JSONL (JSON Lines) patches.
|
||||
const SYSTEM_PROMPT = generateSystemPrompt(dashboardCatalog);
|
||||
|
||||
AVAILABLE COMPONENTS:
|
||||
${componentList.join(", ")}
|
||||
|
||||
COMPONENT DETAILS:
|
||||
- Card: { title?: string, description?: string, padding?: "sm"|"md"|"lg" } - Container with optional title
|
||||
- Grid: { columns?: 1-4, gap?: "sm"|"md"|"lg" } - Grid layout
|
||||
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg", align?: "start"|"center"|"end"|"stretch" } - Flex layout
|
||||
- Metric: { label: string, valuePath: string, format?: "number"|"currency"|"percent", trend?: "up"|"down"|"neutral", trendValue?: string }
|
||||
- Chart: { type: "bar"|"line"|"pie"|"area", dataPath: string, title?: string, height?: number }
|
||||
- Table: { dataPath: string, columns: [{ key: string, label: string, format?: "text"|"currency"|"date"|"badge" }] }
|
||||
- Button: { label: string, action: string, variant?: "primary"|"secondary"|"danger"|"ghost" }
|
||||
- Heading: { text: string, level?: "h1"|"h2"|"h3"|"h4" }
|
||||
- Text: { content: string, variant?: "body"|"caption"|"label", color?: "default"|"muted"|"success"|"warning"|"danger" }
|
||||
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger"|"info" }
|
||||
- Alert: { type: "info"|"success"|"warning"|"error", title: string, message?: string }
|
||||
|
||||
DATA BINDING:
|
||||
- valuePath: "/analytics/revenue" (for single values like Metric)
|
||||
- dataPath: "/analytics/salesByRegion" (for arrays like Chart, Table)
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Output JSONL where each line is a patch operation. Use a FLAT key-based structure:
|
||||
|
||||
OPERATIONS:
|
||||
- {"op":"set","path":"/root","value":"main-card"} - Set the root element key
|
||||
- {"op":"add","path":"/elements/main-card","value":{...}} - Add an element by unique key
|
||||
|
||||
ELEMENT STRUCTURE:
|
||||
{
|
||||
"key": "unique-key",
|
||||
"type": "ComponentType",
|
||||
"props": { ... },
|
||||
"children": ["child-key-1", "child-key-2"] // Array of child element keys
|
||||
}
|
||||
|
||||
RULES:
|
||||
1. First set /root to the root element's key
|
||||
2. Add each element with a unique key using /elements/{key}
|
||||
3. Parent elements list child keys in their "children" array
|
||||
4. Stream elements progressively - parent first, then children
|
||||
5. Each element must have: key, type, props
|
||||
6. Children array contains STRING KEYS, not nested objects
|
||||
|
||||
EXAMPLE - Revenue Dashboard:
|
||||
{"op":"set","path":"/root","value":"main-card"}
|
||||
{"op":"add","path":"/elements/main-card","value":{"key":"main-card","type":"Card","props":{"title":"Revenue Dashboard","padding":"md"},"children":["metrics-grid","chart"]}}
|
||||
{"op":"add","path":"/elements/metrics-grid","value":{"key":"metrics-grid","type":"Grid","props":{"columns":2,"gap":"md"},"children":["revenue-metric","growth-metric"]}}
|
||||
{"op":"add","path":"/elements/revenue-metric","value":{"key":"revenue-metric","type":"Metric","props":{"label":"Total Revenue","valuePath":"/analytics/revenue","format":"currency","trend":"up","trendValue":"+15%"}}}
|
||||
{"op":"add","path":"/elements/growth-metric","value":{"key":"growth-metric","type":"Metric","props":{"label":"Growth Rate","valuePath":"/analytics/growth","format":"percent"}}}
|
||||
{"op":"add","path":"/elements/chart","value":{"key":"chart","type":"Chart","props":{"type":"bar","dataPath":"/analytics/salesByRegion","title":"Sales by Region"}}}
|
||||
|
||||
Generate JSONL patches now:`;
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, context } = await req.json();
|
||||
@@ -69,7 +19,7 @@ export async function POST(req: Request) {
|
||||
}
|
||||
|
||||
const result = streamText({
|
||||
model: "anthropic/claude-opus-4.5",
|
||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: fullPrompt,
|
||||
temperature: 0.7,
|
||||
|
||||
+266
-15
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import {
|
||||
DataProvider,
|
||||
ActionProvider,
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
Renderer,
|
||||
} from "@json-render/react";
|
||||
import { componentRegistry } from "@/components/ui";
|
||||
import { generateNextJSProject } from "@/lib/codegen";
|
||||
import {
|
||||
CodeHighlight,
|
||||
getLanguageFromPath,
|
||||
} from "@/components/code-highlight";
|
||||
|
||||
const INITIAL_DATA = {
|
||||
analytics: {
|
||||
@@ -69,11 +74,24 @@ const ACTION_HANDLERS = {
|
||||
|
||||
function DashboardContent() {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [showCodeExport, setShowCodeExport] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const { tree, isStreaming, error, send, clear } = useUIStream({
|
||||
api: "/api/generate",
|
||||
onError: (err) => console.error("Generation error:", err),
|
||||
});
|
||||
|
||||
const exportedFiles = useMemo(
|
||||
() =>
|
||||
tree
|
||||
? generateNextJSProject(tree, {
|
||||
projectName: "my-dashboard",
|
||||
data: INITIAL_DATA,
|
||||
})
|
||||
: [],
|
||||
[tree],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -83,6 +101,26 @@ function DashboardContent() {
|
||||
[prompt, send],
|
||||
);
|
||||
|
||||
const downloadAllFiles = useCallback(() => {
|
||||
// Create a simple zip-like download by creating individual file downloads
|
||||
// For a real implementation, you'd use a library like JSZip
|
||||
const allContent = exportedFiles
|
||||
.map((f) => `// ========== ${f.path} ==========\n${f.content}`)
|
||||
.join("\n\n");
|
||||
|
||||
const blob = new Blob([allContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "my-dashboard-project.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [exportedFiles]);
|
||||
|
||||
const copyFileContent = useCallback((content: string) => {
|
||||
navigator.clipboard.writeText(content);
|
||||
}, []);
|
||||
|
||||
const examples = [
|
||||
"Revenue dashboard with metrics and chart",
|
||||
"Recent transactions table",
|
||||
@@ -91,6 +129,13 @@ function DashboardContent() {
|
||||
|
||||
const hasElements = tree && Object.keys(tree.elements).length > 0;
|
||||
|
||||
// Auto-select first file when modal opens
|
||||
const activeFile =
|
||||
selectedFile || (exportedFiles.length > 0 ? exportedFiles[0]?.path : null);
|
||||
const activeFileContent = exportedFiles.find(
|
||||
(f) => f.path === activeFile,
|
||||
)?.content;
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
|
||||
<header style={{ marginBottom: 48 }}>
|
||||
@@ -145,20 +190,39 @@ function DashboardContent() {
|
||||
{isStreaming ? "Generating..." : "Generate"}
|
||||
</button>
|
||||
{hasElements && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedFile(null);
|
||||
setShowCodeExport(true);
|
||||
}}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "transparent",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Export Project
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 16,
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -250,6 +314,193 @@ function DashboardContent() {
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Code Export Modal */}
|
||||
{showCodeExport && (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0, 0, 0, 0.7)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 50,
|
||||
}}
|
||||
onClick={() => setShowCodeExport(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "var(--background)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
width: "90%",
|
||||
maxWidth: 1000,
|
||||
height: "80vh",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>
|
||||
Export Next.js Project
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: "4px 0 0",
|
||||
fontSize: 13,
|
||||
color: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{exportedFiles.length} files generated
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
onClick={downloadAllFiles}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--foreground)",
|
||||
color: "var(--background)",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Download All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCodeExport(false)}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
color: "var(--muted)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, display: "flex", overflow: "hidden" }}>
|
||||
{/* File List */}
|
||||
<div
|
||||
style={{
|
||||
width: 240,
|
||||
borderRight: "1px solid var(--border)",
|
||||
overflow: "auto",
|
||||
padding: "8px 0",
|
||||
}}
|
||||
>
|
||||
{exportedFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(file.path)}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
padding: "8px 16px",
|
||||
background:
|
||||
activeFile === file.path
|
||||
? "var(--border)"
|
||||
: "transparent",
|
||||
border: "none",
|
||||
textAlign: "left",
|
||||
fontSize: 13,
|
||||
fontFamily: "monospace",
|
||||
color:
|
||||
activeFile === file.path
|
||||
? "var(--foreground)"
|
||||
: "var(--muted)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{file.path}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File Content */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{activeFile && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
background: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 13, fontFamily: "monospace" }}>
|
||||
{activeFile}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
activeFileContent &&
|
||||
copyFileContent(activeFileContent)
|
||||
}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
background: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
border: "none",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 12,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
overflow: "auto",
|
||||
background: "var(--card)",
|
||||
}}
|
||||
>
|
||||
<CodeHighlight
|
||||
code={activeFileContent || ""}
|
||||
language={getLanguageFromPath(activeFile)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { codeToHtml, type BundledLanguage } from "shiki";
|
||||
|
||||
const vercelDarkTheme = {
|
||||
name: "vercel-dark",
|
||||
type: "dark" as const,
|
||||
colors: {
|
||||
"editor.background": "transparent",
|
||||
"editor.foreground": "#EDEDED",
|
||||
},
|
||||
settings: [
|
||||
{
|
||||
scope: ["comment", "punctuation.definition.comment"],
|
||||
settings: { foreground: "#666666" },
|
||||
},
|
||||
{
|
||||
scope: ["string", "string.quoted", "string.template"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"constant.language.boolean",
|
||||
"constant.language.null",
|
||||
],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword", "storage.type", "storage.modifier"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["keyword.operator", "keyword.control"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.function", "support.function", "meta.function-call"],
|
||||
settings: { foreground: "#7928CA" },
|
||||
},
|
||||
{
|
||||
scope: ["variable", "variable.other", "variable.parameter"],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.name.tag", "support.class.component", "entity.name.type"],
|
||||
settings: { foreground: "#FF0080" },
|
||||
},
|
||||
{
|
||||
scope: ["punctuation", "meta.brace", "meta.bracket"],
|
||||
settings: { foreground: "#888888" },
|
||||
},
|
||||
{
|
||||
scope: [
|
||||
"support.type.property-name",
|
||||
"entity.name.tag.json",
|
||||
"meta.object-literal.key",
|
||||
],
|
||||
settings: { foreground: "#EDEDED" },
|
||||
},
|
||||
{
|
||||
scope: ["entity.other.attribute-name"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
{
|
||||
scope: ["support.type.primitive", "entity.name.type.primitive"],
|
||||
settings: { foreground: "#50E3C2" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
interface CodeHighlightProps {
|
||||
code: string;
|
||||
language?: BundledLanguage;
|
||||
}
|
||||
|
||||
export function CodeHighlight({ code, language = "tsx" }: CodeHighlightProps) {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function highlight() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: vercelDarkTheme,
|
||||
});
|
||||
if (!cancelled) {
|
||||
setHtml(result);
|
||||
}
|
||||
} catch {
|
||||
// Fallback to plain text on error
|
||||
if (!cancelled) {
|
||||
setHtml(`<pre><code>${escapeHtml(code)}</code></pre>`);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
highlight();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
overflow: "auto",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
color: "#EDEDED",
|
||||
}}
|
||||
>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
overflow: "auto",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate language for a file extension
|
||||
*/
|
||||
export function getLanguageFromPath(path: string): BundledLanguage {
|
||||
const ext = path.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "tsx":
|
||||
return "tsx";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "jsx":
|
||||
return "jsx";
|
||||
case "js":
|
||||
return "javascript";
|
||||
case "json":
|
||||
return "json";
|
||||
case "css":
|
||||
return "css";
|
||||
case "md":
|
||||
return "markdown";
|
||||
default:
|
||||
return "typescript";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { UITree } from "@json-render/core";
|
||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||
import { componentTemplates } from "./templates";
|
||||
|
||||
export interface ExportedFile {
|
||||
path: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface GeneratorOptions {
|
||||
projectName?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete Next.js project from a UI tree
|
||||
*/
|
||||
export function generateNextJSProject(
|
||||
tree: UITree,
|
||||
options: GeneratorOptions = {},
|
||||
): ExportedFile[] {
|
||||
const { projectName = "generated-dashboard", data = {} } = options;
|
||||
const files: ExportedFile[] = [];
|
||||
|
||||
// Collect what we need
|
||||
const usedComponents = collectUsedComponents(tree);
|
||||
|
||||
// 1. Generate package.json
|
||||
files.push({
|
||||
path: "package.json",
|
||||
content: generatePackageJson(projectName),
|
||||
});
|
||||
|
||||
// 2. Generate next.config.js
|
||||
files.push({
|
||||
path: "next.config.js",
|
||||
content: `/** @type {import('next').NextConfig} */
|
||||
module.exports = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
`,
|
||||
});
|
||||
|
||||
// 3. Generate tsconfig.json
|
||||
files.push({
|
||||
path: "tsconfig.json",
|
||||
content: JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
target: "ES2017",
|
||||
lib: ["dom", "dom.iterable", "esnext"],
|
||||
allowJs: true,
|
||||
skipLibCheck: true,
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
esModuleInterop: true,
|
||||
module: "esnext",
|
||||
moduleResolution: "bundler",
|
||||
resolveJsonModule: true,
|
||||
isolatedModules: true,
|
||||
jsx: "preserve",
|
||||
incremental: true,
|
||||
plugins: [{ name: "next" }],
|
||||
paths: { "@/*": ["./*"] },
|
||||
},
|
||||
include: [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
],
|
||||
exclude: ["node_modules"],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
|
||||
// 4. Generate globals.css
|
||||
files.push({
|
||||
path: "app/globals.css",
|
||||
content: generateGlobalsCss(),
|
||||
});
|
||||
|
||||
// 5. Generate layout.tsx
|
||||
files.push({
|
||||
path: "app/layout.tsx",
|
||||
content: generateLayout(projectName),
|
||||
});
|
||||
|
||||
// 6. Generate component files (only the ones that are used)
|
||||
for (const componentName of usedComponents) {
|
||||
const template = componentTemplates[componentName];
|
||||
if (template) {
|
||||
files.push({
|
||||
path: `components/ui/${componentName.toLowerCase()}.tsx`,
|
||||
content: template,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Generate components/ui/index.ts
|
||||
files.push({
|
||||
path: "components/ui/index.ts",
|
||||
content: generateComponentIndex(usedComponents),
|
||||
});
|
||||
|
||||
// 8. Generate the main page with the tree
|
||||
files.push({
|
||||
path: "app/page.tsx",
|
||||
content: generateMainPage(tree, usedComponents, data),
|
||||
});
|
||||
|
||||
// 9. Generate README
|
||||
files.push({
|
||||
path: "README.md",
|
||||
content: generateReadme(projectName),
|
||||
});
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function generatePackageJson(name: string): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
name,
|
||||
version: "0.1.0",
|
||||
private: true,
|
||||
scripts: {
|
||||
dev: "next dev",
|
||||
build: "next build",
|
||||
start: "next start",
|
||||
lint: "next lint",
|
||||
},
|
||||
dependencies: {
|
||||
next: "^14.2.0",
|
||||
react: "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
},
|
||||
devDependencies: {
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
typescript: "^5.4.0",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function generateGlobalsCss(): string {
|
||||
return `* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #000;
|
||||
--foreground: #fafafa;
|
||||
--card: #0a0a0a;
|
||||
--border: #262626;
|
||||
--muted: #a3a3a3;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function generateLayout(projectName: string): string {
|
||||
return `import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "${projectName}",
|
||||
description: "Generated dashboard",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function generateComponentIndex(components: Set<string>): string {
|
||||
const exports = Array.from(components)
|
||||
.sort()
|
||||
.map((name) => `export { ${name} } from "./${name.toLowerCase()}";`)
|
||||
.join("\n");
|
||||
|
||||
return exports + "\n";
|
||||
}
|
||||
|
||||
function generateMainPage(
|
||||
tree: UITree,
|
||||
components: Set<string>,
|
||||
data: Record<string, unknown>,
|
||||
): string {
|
||||
const imports = Array.from(components).sort().join(", ");
|
||||
const jsx = generateJSX(tree, tree.root, 4);
|
||||
const dataStr = JSON.stringify(data, null, 2).replace(/\n/g, "\n ");
|
||||
|
||||
return `"use client";
|
||||
|
||||
import { ${imports} } from "@/components/ui";
|
||||
|
||||
const data = ${dataStr};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
|
||||
${jsx}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function generateJSX(tree: UITree, key: string, indent: number): string {
|
||||
const element = tree.elements[key];
|
||||
if (!element) return "";
|
||||
|
||||
const spaces = " ".repeat(indent);
|
||||
const componentName = element.type;
|
||||
|
||||
// Filter out null/undefined props and convert data paths to data references
|
||||
const propsObj: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(element.props)) {
|
||||
if (v === null || v === undefined) continue;
|
||||
|
||||
// Convert *Path props to actual data values
|
||||
if (
|
||||
typeof v === "string" &&
|
||||
(k.endsWith("Path") || k === "bindPath" || k === "dataPath")
|
||||
) {
|
||||
// Keep as a special marker for the component
|
||||
propsObj[k] = v;
|
||||
} else {
|
||||
propsObj[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Add data prop for components that need it
|
||||
const needsData =
|
||||
Object.keys(propsObj).some(
|
||||
(k) => k.endsWith("Path") || k === "bindPath" || k === "dataPath",
|
||||
) || ["Chart", "Table", "Metric", "List"].includes(componentName);
|
||||
|
||||
const propsStr = serializeProps(propsObj);
|
||||
const dataAttr = needsData ? " data={data}" : "";
|
||||
|
||||
const hasChildren = element.children && element.children.length > 0;
|
||||
|
||||
if (!hasChildren) {
|
||||
if (propsStr || dataAttr) {
|
||||
return `${spaces}<${componentName}${dataAttr}${propsStr ? " " + propsStr : ""} />`;
|
||||
}
|
||||
return `${spaces}<${componentName} />`;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
if (propsStr || dataAttr) {
|
||||
lines.push(
|
||||
`${spaces}<${componentName}${dataAttr}${propsStr ? " " + propsStr : ""}>`,
|
||||
);
|
||||
} else {
|
||||
lines.push(`${spaces}<${componentName}>`);
|
||||
}
|
||||
|
||||
for (const childKey of element.children!) {
|
||||
lines.push(generateJSX(tree, childKey, indent + 2));
|
||||
}
|
||||
|
||||
lines.push(`${spaces}</${componentName}>`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function generateReadme(projectName: string): string {
|
||||
return `# ${projectName}
|
||||
|
||||
This dashboard was generated from a json-render UI tree.
|
||||
|
||||
## Getting Started
|
||||
|
||||
\`\`\`bash
|
||||
npm install
|
||||
npm run dev
|
||||
\`\`\`
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) to view the dashboard.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- \`app/page.tsx\` - Main dashboard page
|
||||
- \`components/ui/\` - UI components
|
||||
- \`app/globals.css\` - Global styles
|
||||
`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { generateNextJSProject, type ExportedFile } from "./generator";
|
||||
@@ -0,0 +1,740 @@
|
||||
/**
|
||||
* Standalone component templates that don't depend on json-render
|
||||
* These components receive data as props instead of using hooks
|
||||
*/
|
||||
|
||||
export const componentTemplates: Record<string, string> = {
|
||||
Card: `"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface CardProps {
|
||||
title?: string | null;
|
||||
description?: string | null;
|
||||
padding?: "sm" | "md" | "lg" | null;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ title, description, padding, children }: CardProps) {
|
||||
const paddings: Record<string, string> = {
|
||||
sm: "12px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
>
|
||||
{(title || description) && (
|
||||
<div
|
||||
style={{
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
{description && (
|
||||
<p style={{ margin: "4px 0 0", fontSize: 14, color: "var(--muted)" }}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: paddings[padding || "md"] || "16px" }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Grid: `"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface GridProps {
|
||||
columns?: number | null;
|
||||
gap?: "sm" | "md" | "lg" | null;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Grid({ columns, gap, children }: GridProps) {
|
||||
const gaps: Record<string, string> = {
|
||||
sm: "8px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: \`repeat(\${columns || 2}, 1fr)\`,
|
||||
gap: gaps[gap || "md"],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Stack: `"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface StackProps {
|
||||
direction?: "horizontal" | "vertical" | null;
|
||||
gap?: "sm" | "md" | "lg" | null;
|
||||
align?: "start" | "center" | "end" | "stretch" | null;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Stack({ direction, gap, align, children }: StackProps) {
|
||||
const gaps: Record<string, string> = {
|
||||
sm: "8px",
|
||||
md: "16px",
|
||||
lg: "24px",
|
||||
};
|
||||
const alignments: Record<string, string> = {
|
||||
start: "flex-start",
|
||||
center: "center",
|
||||
end: "flex-end",
|
||||
stretch: "stretch",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||
gap: gaps[gap || "md"],
|
||||
alignItems: alignments[align || "stretch"],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Metric: `"use client";
|
||||
|
||||
interface MetricProps {
|
||||
label: string;
|
||||
valuePath: string;
|
||||
format?: "number" | "currency" | "percent" | null;
|
||||
trend?: "up" | "down" | "neutral" | null;
|
||||
trendValue?: string | null;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
if (!path) return obj;
|
||||
const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function Metric({ label, valuePath, format, trend, trendValue, data }: MetricProps) {
|
||||
const rawValue = data ? getByPath(data, valuePath) : undefined;
|
||||
|
||||
let displayValue = String(rawValue ?? "-");
|
||||
if (format === "currency" && typeof rawValue === "number") {
|
||||
displayValue = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(rawValue);
|
||||
} else if (format === "percent" && typeof rawValue === "number") {
|
||||
displayValue = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
minimumFractionDigits: 1,
|
||||
}).format(rawValue);
|
||||
} else if (format === "number" && typeof rawValue === "number") {
|
||||
displayValue = new Intl.NumberFormat("en-US").format(rawValue);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
<span style={{ fontSize: 14, color: "var(--muted)" }}>{label}</span>
|
||||
<span style={{ fontSize: 32, fontWeight: 600 }}>{displayValue}</span>
|
||||
{(trend || trendValue) && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color:
|
||||
trend === "up"
|
||||
? "#22c55e"
|
||||
: trend === "down"
|
||||
? "#ef4444"
|
||||
: "var(--muted)",
|
||||
}}
|
||||
>
|
||||
{trend === "up" ? "+" : trend === "down" ? "-" : ""}
|
||||
{trendValue}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Chart: `"use client";
|
||||
|
||||
interface ChartProps {
|
||||
type?: "bar" | "line" | "pie" | "area";
|
||||
dataPath: string;
|
||||
title?: string | null;
|
||||
height?: number | null;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
if (!path) return obj;
|
||||
const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function Chart({ title, dataPath, height, data }: ChartProps) {
|
||||
const chartData = data
|
||||
? (getByPath(data, dataPath) as Array<{ label: string; value: number }> | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!chartData || !Array.isArray(chartData)) {
|
||||
return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
|
||||
}
|
||||
|
||||
const maxValue = Math.max(...chartData.map((d) => d.value));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{title && (
|
||||
<h4 style={{ margin: "0 0 16px", fontSize: 14, fontWeight: 600 }}>
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
<div
|
||||
style={{ display: "flex", gap: 8, alignItems: "flex-end", height: height || 120 }}
|
||||
>
|
||||
{chartData.map((d, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: \`\${(d.value / maxValue) * 100}%\`,
|
||||
background: "var(--foreground)",
|
||||
borderRadius: "4px 4px 0 0",
|
||||
minHeight: 4,
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>
|
||||
{d.label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Table: `"use client";
|
||||
|
||||
interface TableProps {
|
||||
dataPath: string;
|
||||
columns: Array<{ key: string; label: string; format?: "text" | "currency" | "date" | "badge" | null }>;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
if (!path) return obj;
|
||||
const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function Table({ dataPath, columns, data }: TableProps) {
|
||||
const tableData = data
|
||||
? (getByPath(data, dataPath) as Array<Record<string, unknown>> | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!tableData || !Array.isArray(tableData)) {
|
||||
return <div style={{ padding: 20, color: "var(--muted)" }}>No data</div>;
|
||||
}
|
||||
|
||||
const formatCell = (value: unknown, format?: string | null) => {
|
||||
if (value === null || value === undefined) return "-";
|
||||
if (format === "currency" && typeof value === "number") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(value);
|
||||
}
|
||||
if (format === "date" && typeof value === "string") {
|
||||
return new Date(value).toLocaleDateString();
|
||||
}
|
||||
if (format === "badge") {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: "var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={{
|
||||
textAlign: "left",
|
||||
padding: "12px 8px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--muted)",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.05em",
|
||||
}}
|
||||
>
|
||||
{col.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
style={{
|
||||
padding: "12px 8px",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{formatCell(row[col.key], col.format)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Button: `"use client";
|
||||
|
||||
interface ButtonProps {
|
||||
label: string;
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost" | null;
|
||||
size?: "sm" | "md" | "lg" | null;
|
||||
action?: string;
|
||||
disabled?: boolean | null;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Button({ label, variant, disabled, onClick }: ButtonProps) {
|
||||
const variants: Record<string, React.CSSProperties> = {
|
||||
primary: {
|
||||
background: "var(--foreground)",
|
||||
color: "var(--background)",
|
||||
border: "none",
|
||||
},
|
||||
secondary: {
|
||||
background: "transparent",
|
||||
color: "var(--foreground)",
|
||||
border: "1px solid var(--border)",
|
||||
},
|
||||
danger: { background: "#dc2626", color: "#fff", border: "none" },
|
||||
ghost: { background: "transparent", color: "var(--muted)", border: "none" },
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!!disabled}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
borderRadius: "var(--radius)",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
...variants[variant || "primary"],
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Heading: `"use client";
|
||||
|
||||
interface HeadingProps {
|
||||
text: string;
|
||||
level?: "h1" | "h2" | "h3" | "h4" | null;
|
||||
}
|
||||
|
||||
export function Heading({ text, level }: HeadingProps) {
|
||||
const sizes: Record<string, { fontSize: number; fontWeight: number }> = {
|
||||
h1: { fontSize: 32, fontWeight: 700 },
|
||||
h2: { fontSize: 24, fontWeight: 600 },
|
||||
h3: { fontSize: 20, fontWeight: 600 },
|
||||
h4: { fontSize: 16, fontWeight: 600 },
|
||||
};
|
||||
|
||||
const style = sizes[level || "h2"];
|
||||
const Tag = (level || "h2") as keyof JSX.IntrinsicElements;
|
||||
|
||||
return (
|
||||
<Tag style={{ margin: 0, ...style }}>
|
||||
{text}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Text: `"use client";
|
||||
|
||||
interface TextProps {
|
||||
content: string;
|
||||
variant?: "body" | "caption" | "label" | null;
|
||||
color?: "default" | "muted" | "success" | "warning" | "danger" | null;
|
||||
}
|
||||
|
||||
export function Text({ content, variant, color }: TextProps) {
|
||||
const sizes: Record<string, number> = {
|
||||
body: 14,
|
||||
caption: 12,
|
||||
label: 13,
|
||||
};
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
default: "var(--foreground)",
|
||||
muted: "var(--muted)",
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
danger: "#ef4444",
|
||||
};
|
||||
|
||||
return (
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: sizes[variant || "body"],
|
||||
color: colors[color || "default"],
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Badge: `"use client";
|
||||
|
||||
interface BadgeProps {
|
||||
text: string;
|
||||
variant?: "default" | "success" | "warning" | "danger" | "info" | null;
|
||||
}
|
||||
|
||||
export function Badge({ text, variant }: BadgeProps) {
|
||||
const colors: Record<string, { bg: string; fg: string }> = {
|
||||
default: { bg: "var(--border)", fg: "var(--foreground)" },
|
||||
success: { bg: "#22c55e20", fg: "#22c55e" },
|
||||
warning: { bg: "#eab30820", fg: "#eab308" },
|
||||
danger: { bg: "#ef444420", fg: "#ef4444" },
|
||||
info: { bg: "#3b82f620", fg: "#3b82f6" },
|
||||
};
|
||||
|
||||
const { bg, fg } = colors[variant || "default"];
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: bg,
|
||||
color: fg,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Alert: `"use client";
|
||||
|
||||
interface AlertProps {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
title: string;
|
||||
message?: string | null;
|
||||
dismissible?: boolean | null;
|
||||
}
|
||||
|
||||
export function Alert({ type, title, message }: AlertProps) {
|
||||
const colors: Record<string, string> = {
|
||||
info: "var(--muted)",
|
||||
success: "#22c55e",
|
||||
warning: "#eab308",
|
||||
error: "#ef4444",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 16px",
|
||||
borderRadius: "var(--radius)",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: colors[type],
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{title}</div>
|
||||
{message && (
|
||||
<div style={{ fontSize: 13, color: "var(--muted)", marginTop: 4 }}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Divider: `"use client";
|
||||
|
||||
interface DividerProps {
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export function Divider({ label }: DividerProps) {
|
||||
if (label) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border)" }} />
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{label}</span>
|
||||
<div style={{ flex: 1, height: 1, background: "var(--border)" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <hr style={{ border: "none", height: 1, background: "var(--border)", margin: "16px 0" }} />;
|
||||
}
|
||||
`,
|
||||
|
||||
Empty: `"use client";
|
||||
|
||||
interface EmptyProps {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
action?: string | null;
|
||||
actionLabel?: string | null;
|
||||
}
|
||||
|
||||
export function Empty({ title, description }: EmptyProps) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px" }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 500 }}>{title}</div>
|
||||
{description && (
|
||||
<div style={{ fontSize: 14, color: "var(--muted)", marginTop: 8 }}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
Select: `"use client";
|
||||
|
||||
interface SelectProps {
|
||||
label?: string | null;
|
||||
bindPath: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
placeholder?: string | null;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Select({ label, options, placeholder, value, onChange }: SelectProps) {
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<select
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
color: "var(--foreground)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
DatePicker: `"use client";
|
||||
|
||||
interface DatePickerProps {
|
||||
label?: string | null;
|
||||
bindPath: string;
|
||||
placeholder?: string | null;
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function DatePicker({ label, placeholder, value, onChange }: DatePickerProps) {
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<label style={{ display: "block", fontSize: 13, marginBottom: 4, color: "var(--muted)" }}>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
type="date"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={placeholder || ""}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--card)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius)",
|
||||
color: "var(--foreground)",
|
||||
fontSize: 14,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
List: `"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ListProps {
|
||||
dataPath: string;
|
||||
emptyMessage?: string | null;
|
||||
data?: Record<string, unknown>;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function getByPath(obj: unknown, path: string): unknown {
|
||||
if (!path) return obj;
|
||||
const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||
let current: unknown = obj;
|
||||
for (const segment of segments) {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
if (typeof current === "object") {
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function List({ dataPath, emptyMessage, data, children }: ListProps) {
|
||||
const listData = data
|
||||
? (getByPath(data, dataPath) as unknown[] | undefined)
|
||||
: undefined;
|
||||
|
||||
if (!listData || !Array.isArray(listData) || listData.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 20, color: "var(--muted)", textAlign: "center" }}>
|
||||
{emptyMessage || "No items"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack --port 3001",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint --max-warnings 0"
|
||||
@@ -12,11 +12,13 @@
|
||||
"dependencies": {
|
||||
"@ai-sdk/gateway": "^3.0.13",
|
||||
"ai": "^6.0.33",
|
||||
"@json-render/codegen": "workspace:*",
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"next": "16.1.1",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"shiki": "^3.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# @json-render/codegen
|
||||
|
||||
Utilities for generating code from json-render UI trees.
|
||||
|
||||
This package provides framework-agnostic utilities for building code generators. Use these utilities to create custom code exporters for your specific framework (Next.js, Remix, etc.).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/codegen
|
||||
# or
|
||||
pnpm add @json-render/codegen
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
### Tree Traversal
|
||||
|
||||
```typescript
|
||||
import { traverseTree, collectUsedComponents, collectDataPaths, collectActions } from '@json-render/codegen';
|
||||
|
||||
// Walk the tree depth-first
|
||||
traverseTree(tree, (element, depth, parent) => {
|
||||
console.log(`${' '.repeat(depth * 2)}${element.type}`);
|
||||
});
|
||||
|
||||
// Get all component types used
|
||||
const components = collectUsedComponents(tree);
|
||||
// Set { 'Card', 'Metric', 'Button' }
|
||||
|
||||
// Get all data paths referenced
|
||||
const dataPaths = collectDataPaths(tree);
|
||||
// Set { 'analytics/revenue', 'user/name' }
|
||||
|
||||
// Get all action names
|
||||
const actions = collectActions(tree);
|
||||
// Set { 'submit_form', 'refresh_data' }
|
||||
```
|
||||
|
||||
### Serialization
|
||||
|
||||
```typescript
|
||||
import { serializePropValue, serializeProps, escapeString } from '@json-render/codegen';
|
||||
|
||||
// Serialize a single value
|
||||
serializePropValue("hello");
|
||||
// { value: '"hello"', needsBraces: false }
|
||||
|
||||
serializePropValue(42);
|
||||
// { value: '42', needsBraces: true }
|
||||
|
||||
serializePropValue({ path: 'user/name' });
|
||||
// { value: '{ path: "user/name" }', needsBraces: true }
|
||||
|
||||
// Serialize props for JSX
|
||||
serializeProps({ title: "Dashboard", columns: 3, disabled: true });
|
||||
// 'title="Dashboard" columns={3} disabled'
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
```typescript
|
||||
import type { GeneratedFile, CodeGenerator } from '@json-render/codegen';
|
||||
|
||||
// Implement your own code generator
|
||||
const myGenerator: CodeGenerator = {
|
||||
generate(tree) {
|
||||
return [
|
||||
{ path: 'package.json', content: '...' },
|
||||
{ path: 'app/page.tsx', content: '...' },
|
||||
];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Building a Custom Generator
|
||||
|
||||
See the `examples/dashboard` for a complete example of building a Next.js code generator using these utilities.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
collectUsedComponents,
|
||||
collectDataPaths,
|
||||
traverseTree,
|
||||
serializeProps,
|
||||
type GeneratedFile
|
||||
} from '@json-render/codegen';
|
||||
import type { UITree } from '@json-render/core';
|
||||
|
||||
export function generateNextJSProject(tree: UITree): GeneratedFile[] {
|
||||
const files: GeneratedFile[] = [];
|
||||
const components = collectUsedComponents(tree);
|
||||
|
||||
// Generate package.json
|
||||
files.push({
|
||||
path: 'package.json',
|
||||
content: JSON.stringify({
|
||||
name: 'my-generated-app',
|
||||
dependencies: {
|
||||
next: '^14.0.0',
|
||||
react: '^18.0.0',
|
||||
}
|
||||
}, null, 2)
|
||||
});
|
||||
|
||||
// Generate component files...
|
||||
// Generate main page...
|
||||
|
||||
return files;
|
||||
}
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"codegen",
|
||||
"code-generation",
|
||||
"export"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/codegen"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.mjs",
|
||||
"require": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@repo/typescript-config": "workspace:*",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
traverseTree,
|
||||
collectUsedComponents,
|
||||
collectDataPaths,
|
||||
collectActions,
|
||||
type TreeVisitor,
|
||||
} from "./traverse";
|
||||
|
||||
export {
|
||||
serializePropValue,
|
||||
serializeProps,
|
||||
escapeString,
|
||||
type SerializeOptions,
|
||||
} from "./serialize";
|
||||
|
||||
export type { GeneratedFile, CodeGenerator } from "./types";
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Options for serialization
|
||||
*/
|
||||
export interface SerializeOptions {
|
||||
/** Quote style for strings */
|
||||
quotes?: "single" | "double";
|
||||
/** Indent for objects/arrays */
|
||||
indent?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<SerializeOptions> = {
|
||||
quotes: "double",
|
||||
indent: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* Escape a string for use in code
|
||||
*/
|
||||
export function escapeString(
|
||||
str: string,
|
||||
quotes: "single" | "double" = "double",
|
||||
): string {
|
||||
const quoteChar = quotes === "single" ? "'" : '"';
|
||||
const escaped = str
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\t/g, "\\t");
|
||||
|
||||
if (quotes === "single") {
|
||||
return escaped.replace(/'/g, "\\'");
|
||||
}
|
||||
return escaped.replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a single prop value to a code string
|
||||
*
|
||||
* @returns Object with `value` (the serialized string) and `needsBraces` (whether JSX needs {})
|
||||
*/
|
||||
export function serializePropValue(
|
||||
value: unknown,
|
||||
options: SerializeOptions = {},
|
||||
): { value: string; needsBraces: boolean } {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
const q = opts.quotes === "single" ? "'" : '"';
|
||||
|
||||
if (value === null) {
|
||||
return { value: "null", needsBraces: true };
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
return { value: "undefined", needsBraces: true };
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return {
|
||||
value: `${q}${escapeString(value, opts.quotes)}${q}`,
|
||||
needsBraces: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return { value: String(value), needsBraces: true };
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
if (value === true) {
|
||||
return { value: "true", needsBraces: false }; // Can use shorthand
|
||||
}
|
||||
return { value: "false", needsBraces: true };
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((v) => serializePropValue(v, opts).value);
|
||||
return { value: `[${items.join(", ")}]`, needsBraces: true };
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
// Check for path reference
|
||||
if (
|
||||
"path" in value &&
|
||||
typeof (value as { path: unknown }).path === "string"
|
||||
) {
|
||||
return {
|
||||
value: `{ path: ${q}${escapeString((value as { path: string }).path, opts.quotes)}${q} }`,
|
||||
needsBraces: true,
|
||||
};
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
.filter(([, v]) => v !== undefined)
|
||||
.map(([k, v]) => {
|
||||
const serialized = serializePropValue(v, opts).value;
|
||||
// Use shorthand if key matches value for simple identifiers
|
||||
return `${k}: ${serialized}`;
|
||||
});
|
||||
|
||||
return { value: `{ ${entries.join(", ")} }`, needsBraces: true };
|
||||
}
|
||||
|
||||
return { value: String(value), needsBraces: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize props object to JSX attributes string
|
||||
*/
|
||||
export function serializeProps(
|
||||
props: Record<string, unknown>,
|
||||
options: SerializeOptions = {},
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
const serialized = serializePropValue(value, options);
|
||||
|
||||
// Boolean true can be shorthand
|
||||
if (typeof value === "boolean" && value === true) {
|
||||
parts.push(key);
|
||||
} else if (serialized.needsBraces) {
|
||||
parts.push(`${key}={${serialized.value}}`);
|
||||
} else {
|
||||
parts.push(`${key}=${serialized.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
traverseTree,
|
||||
collectUsedComponents,
|
||||
collectDataPaths,
|
||||
collectActions,
|
||||
} from "./traverse";
|
||||
import type { UITree } from "@json-render/core";
|
||||
|
||||
describe("traverseTree", () => {
|
||||
it("visits all elements depth-first", () => {
|
||||
const tree: UITree = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Card",
|
||||
props: {},
|
||||
children: ["child1", "child2"],
|
||||
},
|
||||
child1: {
|
||||
key: "child1",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
child2: {
|
||||
key: "child2",
|
||||
type: "Button",
|
||||
props: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const visited: string[] = [];
|
||||
traverseTree(tree, (element) => {
|
||||
visited.push(element.key);
|
||||
});
|
||||
|
||||
expect(visited).toEqual(["root", "child1", "child2"]);
|
||||
});
|
||||
|
||||
it("handles empty tree", () => {
|
||||
const visited: string[] = [];
|
||||
traverseTree(null as unknown as UITree, (element) => {
|
||||
visited.push(element.key);
|
||||
});
|
||||
expect(visited).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectUsedComponents", () => {
|
||||
it("collects unique component types", () => {
|
||||
const tree: UITree = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Card",
|
||||
props: {},
|
||||
children: ["child1", "child2"],
|
||||
},
|
||||
child1: {
|
||||
key: "child1",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
child2: {
|
||||
key: "child2",
|
||||
type: "Text",
|
||||
props: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const components = collectUsedComponents(tree);
|
||||
expect(components).toEqual(new Set(["Card", "Text"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectDataPaths", () => {
|
||||
it("collects paths from valuePath props", () => {
|
||||
const tree: UITree = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Metric",
|
||||
props: { valuePath: "analytics/revenue" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = collectDataPaths(tree);
|
||||
expect(paths).toEqual(new Set(["analytics/revenue"]));
|
||||
});
|
||||
|
||||
it("collects paths from dynamic value objects", () => {
|
||||
const tree: UITree = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Text",
|
||||
props: { content: { path: "user/name" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const paths = collectDataPaths(tree);
|
||||
expect(paths).toEqual(new Set(["user/name"]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectActions", () => {
|
||||
it("collects action names from props", () => {
|
||||
const tree: UITree = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
key: "root",
|
||||
type: "Button",
|
||||
props: { action: "submit_form" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = collectActions(tree);
|
||||
expect(actions).toEqual(new Set(["submit_form"]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { UITree, UIElement } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Visitor function for tree traversal
|
||||
*/
|
||||
export interface TreeVisitor {
|
||||
(element: UIElement, depth: number, parent: UIElement | null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traverse a UI tree depth-first
|
||||
*/
|
||||
export function traverseTree(
|
||||
tree: UITree,
|
||||
visitor: TreeVisitor,
|
||||
startKey?: string,
|
||||
): void {
|
||||
if (!tree || !tree.root) return;
|
||||
|
||||
const rootKey = startKey ?? tree.root;
|
||||
const rootElement = tree.elements[rootKey];
|
||||
if (!rootElement) return;
|
||||
|
||||
function visit(key: string, depth: number, parent: UIElement | null): void {
|
||||
const element = tree.elements[key];
|
||||
if (!element) return;
|
||||
|
||||
visitor(element, depth, parent);
|
||||
|
||||
if (element.children) {
|
||||
for (const childKey of element.children) {
|
||||
visit(childKey, depth + 1, element);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(rootKey, 0, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all unique component types used in a tree
|
||||
*/
|
||||
export function collectUsedComponents(tree: UITree): Set<string> {
|
||||
const components = new Set<string>();
|
||||
|
||||
traverseTree(tree, (element) => {
|
||||
components.add(element.type);
|
||||
});
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all data paths referenced in a tree
|
||||
*/
|
||||
export function collectDataPaths(tree: UITree): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
|
||||
traverseTree(tree, (element) => {
|
||||
// Check props for data paths
|
||||
for (const [propName, propValue] of Object.entries(element.props)) {
|
||||
// Check for path props (e.g., valuePath, dataPath, bindPath)
|
||||
if (typeof propValue === "string") {
|
||||
if (
|
||||
propName.endsWith("Path") ||
|
||||
propName === "bindPath" ||
|
||||
propName === "dataPath"
|
||||
) {
|
||||
paths.add(propValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dynamic value objects with path
|
||||
if (
|
||||
propValue &&
|
||||
typeof propValue === "object" &&
|
||||
"path" in propValue &&
|
||||
typeof (propValue as { path: unknown }).path === "string"
|
||||
) {
|
||||
paths.add((propValue as { path: string }).path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check visibility conditions for paths
|
||||
if (element.visible && typeof element.visible === "object") {
|
||||
collectPathsFromCondition(element.visible, paths);
|
||||
}
|
||||
});
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
function collectPathsFromCondition(
|
||||
condition: unknown,
|
||||
paths: Set<string>,
|
||||
): void {
|
||||
if (!condition || typeof condition !== "object") return;
|
||||
|
||||
const cond = condition as Record<string, unknown>;
|
||||
|
||||
if ("path" in cond && typeof cond.path === "string") {
|
||||
paths.add(cond.path);
|
||||
}
|
||||
|
||||
if ("and" in cond && Array.isArray(cond.and)) {
|
||||
for (const sub of cond.and) {
|
||||
collectPathsFromCondition(sub, paths);
|
||||
}
|
||||
}
|
||||
|
||||
if ("or" in cond && Array.isArray(cond.or)) {
|
||||
for (const sub of cond.or) {
|
||||
collectPathsFromCondition(sub, paths);
|
||||
}
|
||||
}
|
||||
|
||||
if ("not" in cond) {
|
||||
collectPathsFromCondition(cond.not, paths);
|
||||
}
|
||||
|
||||
// Check comparison operators
|
||||
for (const op of ["eq", "neq", "gt", "gte", "lt", "lte"]) {
|
||||
if (op in cond && Array.isArray(cond[op])) {
|
||||
for (const operand of cond[op] as unknown[]) {
|
||||
if (
|
||||
operand &&
|
||||
typeof operand === "object" &&
|
||||
"path" in operand &&
|
||||
typeof (operand as { path: unknown }).path === "string"
|
||||
) {
|
||||
paths.add((operand as { path: string }).path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all action names used in a tree
|
||||
*/
|
||||
export function collectActions(tree: UITree): Set<string> {
|
||||
const actions = new Set<string>();
|
||||
|
||||
traverseTree(tree, (element) => {
|
||||
for (const propValue of Object.values(element.props)) {
|
||||
// Check for action prop (string action name)
|
||||
if (typeof propValue === "string" && propValue.startsWith("action:")) {
|
||||
actions.add(propValue.slice(7));
|
||||
}
|
||||
|
||||
// Check for action objects
|
||||
if (
|
||||
propValue &&
|
||||
typeof propValue === "object" &&
|
||||
"name" in propValue &&
|
||||
typeof (propValue as { name: unknown }).name === "string"
|
||||
) {
|
||||
actions.add((propValue as { name: string }).name);
|
||||
}
|
||||
}
|
||||
|
||||
// Also check direct action prop
|
||||
const actionProp = element.props.action;
|
||||
if (typeof actionProp === "string") {
|
||||
actions.add(actionProp);
|
||||
}
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { UITree } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Represents a generated file
|
||||
*/
|
||||
export interface GeneratedFile {
|
||||
/** File path relative to project root */
|
||||
path: string;
|
||||
/** File contents */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for code generators
|
||||
*/
|
||||
export interface CodeGenerator {
|
||||
/** Generate files from a UI tree */
|
||||
generate(tree: UITree): GeneratedFile[];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@json-render/core"],
|
||||
});
|
||||
@@ -295,3 +295,240 @@ export type InferCatalogComponentProps<
|
||||
> = {
|
||||
[K in keyof C["components"]]: z.infer<C["components"][K]["props"]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Internal Zod definition type for introspection
|
||||
*/
|
||||
interface ZodDefInternal {
|
||||
typeName?: string;
|
||||
value?: unknown;
|
||||
values?: unknown;
|
||||
type?: z.ZodTypeAny;
|
||||
shape?: () => Record<string, z.ZodTypeAny>;
|
||||
innerType?: z.ZodTypeAny;
|
||||
options?: z.ZodTypeAny[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Zod type into a human-readable string for prompts
|
||||
*/
|
||||
function formatZodType(schema: z.ZodTypeAny, isOptional = false): string {
|
||||
const def = schema._def as unknown as ZodDefInternal;
|
||||
const typeName = def.typeName ?? "";
|
||||
|
||||
let result: string;
|
||||
|
||||
switch (typeName) {
|
||||
case "ZodString":
|
||||
result = "string";
|
||||
break;
|
||||
case "ZodNumber":
|
||||
result = "number";
|
||||
break;
|
||||
case "ZodBoolean":
|
||||
result = "boolean";
|
||||
break;
|
||||
case "ZodLiteral":
|
||||
result = JSON.stringify(def.value);
|
||||
break;
|
||||
case "ZodEnum":
|
||||
result = (def.values as string[]).map((v) => `"${v}"`).join("|");
|
||||
break;
|
||||
case "ZodNativeEnum":
|
||||
result = Object.values(def.values as Record<string, string>)
|
||||
.map((v) => `"${v}"`)
|
||||
.join("|");
|
||||
break;
|
||||
case "ZodArray":
|
||||
result = def.type
|
||||
? `Array<${formatZodType(def.type)}>`
|
||||
: "Array<unknown>";
|
||||
break;
|
||||
case "ZodObject": {
|
||||
if (!def.shape) {
|
||||
result = "object";
|
||||
break;
|
||||
}
|
||||
const shape = def.shape();
|
||||
const props = Object.entries(shape)
|
||||
.map(([key, value]) => {
|
||||
const innerDef = value._def as unknown as ZodDefInternal;
|
||||
const innerOptional =
|
||||
innerDef.typeName === "ZodOptional" ||
|
||||
innerDef.typeName === "ZodNullable";
|
||||
return `${key}${innerOptional ? "?" : ""}: ${formatZodType(value)}`;
|
||||
})
|
||||
.join(", ");
|
||||
result = `{ ${props} }`;
|
||||
break;
|
||||
}
|
||||
case "ZodOptional":
|
||||
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
|
||||
case "ZodNullable":
|
||||
return def.innerType ? formatZodType(def.innerType, true) : "unknown?";
|
||||
case "ZodDefault":
|
||||
return def.innerType
|
||||
? formatZodType(def.innerType, isOptional)
|
||||
: "unknown";
|
||||
case "ZodUnion":
|
||||
result = def.options
|
||||
? def.options.map((opt) => formatZodType(opt)).join("|")
|
||||
: "unknown";
|
||||
break;
|
||||
case "ZodNull":
|
||||
result = "null";
|
||||
break;
|
||||
case "ZodUndefined":
|
||||
result = "undefined";
|
||||
break;
|
||||
case "ZodAny":
|
||||
result = "any";
|
||||
break;
|
||||
case "ZodUnknown":
|
||||
result = "unknown";
|
||||
break;
|
||||
default:
|
||||
result = "unknown";
|
||||
}
|
||||
|
||||
return isOptional ? `${result}?` : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract props from a Zod object schema as formatted entries
|
||||
*/
|
||||
function extractPropsFromSchema(
|
||||
schema: z.ZodTypeAny,
|
||||
): Array<{ name: string; type: string; optional: boolean }> {
|
||||
const def = schema._def as unknown as ZodDefInternal;
|
||||
const typeName = def.typeName ?? "";
|
||||
|
||||
if (typeName !== "ZodObject" || !def.shape) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const shape = def.shape();
|
||||
return Object.entries(shape).map(([name, value]) => {
|
||||
const innerDef = value._def as unknown as ZodDefInternal;
|
||||
const optional =
|
||||
innerDef.typeName === "ZodOptional" ||
|
||||
innerDef.typeName === "ZodNullable";
|
||||
return {
|
||||
name,
|
||||
type: formatZodType(value),
|
||||
optional,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format component props as a compact object notation
|
||||
*/
|
||||
function formatPropsCompact(
|
||||
props: Array<{ name: string; type: string; optional: boolean }>,
|
||||
): string {
|
||||
if (props.length === 0) return "{}";
|
||||
const entries = props.map(
|
||||
(p) => `${p.name}${p.optional ? "?" : ""}: ${p.type}`,
|
||||
);
|
||||
return `{ ${entries.join(", ")} }`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for generating system prompts
|
||||
*/
|
||||
export interface SystemPromptOptions {
|
||||
/** System message intro (replaces default) */
|
||||
system?: string;
|
||||
/** Additional rules to append to the rules section */
|
||||
customRules?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete system prompt for AI that can generate UI from a catalog.
|
||||
* This produces a ready-to-use prompt that stays in sync with the catalog definition.
|
||||
*/
|
||||
export function generateSystemPrompt<
|
||||
TComponents extends Record<string, ComponentDefinition>,
|
||||
TActions extends Record<string, ActionDefinition>,
|
||||
TFunctions extends Record<string, ValidationFunction>,
|
||||
>(
|
||||
catalog: Catalog<TComponents, TActions, TFunctions>,
|
||||
options: SystemPromptOptions = {},
|
||||
): string {
|
||||
const {
|
||||
system = "You are a UI generator that outputs JSONL (JSON Lines) patches.",
|
||||
customRules = [],
|
||||
} = options;
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// System intro
|
||||
lines.push(system);
|
||||
lines.push("");
|
||||
|
||||
// Components section
|
||||
const componentCount = catalog.componentNames.length;
|
||||
lines.push(`AVAILABLE COMPONENTS (${componentCount}):`);
|
||||
lines.push("");
|
||||
|
||||
for (const name of catalog.componentNames) {
|
||||
const def = catalog.components[name]!;
|
||||
const props = extractPropsFromSchema(def.props);
|
||||
const propsStr = formatPropsCompact(props);
|
||||
const hasChildrenStr = def.hasChildren ? " Has children." : "";
|
||||
const descStr = def.description ? ` ${def.description}` : "";
|
||||
|
||||
lines.push(`- ${String(name)}: ${propsStr}${descStr}${hasChildrenStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
|
||||
// Actions section
|
||||
if (catalog.actionNames.length > 0) {
|
||||
lines.push("AVAILABLE ACTIONS:");
|
||||
lines.push("");
|
||||
for (const name of catalog.actionNames) {
|
||||
const def = catalog.actions[name]!;
|
||||
lines.push(
|
||||
`- ${String(name)}${def.description ? `: ${def.description}` : ""}`,
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// Output format
|
||||
lines.push("OUTPUT FORMAT (JSONL):");
|
||||
lines.push('{"op":"set","path":"/root","value":"element-key"}');
|
||||
lines.push(
|
||||
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// Rules
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"First line sets /root to root element key",
|
||||
"Add elements with /elements/{key}",
|
||||
"Children array contains string keys, not objects",
|
||||
"Parent first, then children",
|
||||
"Each element needs: key, type, props",
|
||||
"ONLY use props listed above - never invent new props",
|
||||
];
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
allRules.forEach((rule, i) => {
|
||||
lines.push(`${i + 1}. ${rule}`);
|
||||
});
|
||||
lines.push("");
|
||||
|
||||
// Custom validation functions (only if catalog has them)
|
||||
if (catalog.functionNames.length > 0) {
|
||||
lines.push("CUSTOM VALIDATION FUNCTIONS:");
|
||||
lines.push(catalog.functionNames.map(String).join(", "));
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// End with prompt
|
||||
lines.push("Generate JSONL:");
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -86,6 +86,11 @@ export type {
|
||||
CatalogConfig,
|
||||
Catalog,
|
||||
InferCatalogComponentProps,
|
||||
SystemPromptOptions,
|
||||
} from "./catalog";
|
||||
|
||||
export { createCatalog, generateCatalogPrompt } from "./catalog";
|
||||
export {
|
||||
createCatalog,
|
||||
generateCatalogPrompt,
|
||||
generateSystemPrompt,
|
||||
} from "./catalog";
|
||||
|
||||
@@ -132,8 +132,12 @@ export function useUIStream({
|
||||
setIsStreaming(true);
|
||||
setError(null);
|
||||
|
||||
// Start with an empty tree
|
||||
let currentTree: UITree = { root: "", elements: {} };
|
||||
// Start with previous tree if provided, otherwise empty tree
|
||||
const previousTree = context?.previousTree as UITree | undefined;
|
||||
let currentTree: UITree =
|
||||
previousTree && previousTree.root
|
||||
? { ...previousTree, elements: { ...previousTree.elements } }
|
||||
: { root: "", elements: {} };
|
||||
setTree(currentTree);
|
||||
|
||||
try {
|
||||
@@ -149,7 +153,19 @@ export function useUIStream({
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error: ${response.status}`);
|
||||
// Try to parse JSON error response for better error messages
|
||||
let errorMessage = `HTTP error: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
} else if (errorData.error) {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
|
||||
Generated
+75
@@ -41,6 +41,9 @@ importers:
|
||||
'@ai-sdk/gateway':
|
||||
specifier: ^3.0.13
|
||||
version: 3.0.13(zod@4.3.5)
|
||||
'@json-render/codegen':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codegen
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
@@ -56,6 +59,12 @@ importers:
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.13
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@upstash/ratelimit':
|
||||
specifier: ^2.0.8
|
||||
version: 2.0.8(@upstash/redis@1.36.1)
|
||||
'@upstash/redis':
|
||||
specifier: ^1.36.1
|
||||
version: 1.36.1
|
||||
'@vercel/analytics':
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
|
||||
@@ -86,6 +95,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
react-resizable-panels:
|
||||
specifier: ^4.4.1
|
||||
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
shiki:
|
||||
specifier: ^3.21.0
|
||||
version: 3.21.0
|
||||
@@ -138,6 +150,9 @@ importers:
|
||||
'@ai-sdk/gateway':
|
||||
specifier: ^3.0.13
|
||||
version: 3.0.13(zod@4.3.5)
|
||||
'@json-render/codegen':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/codegen
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
@@ -156,6 +171,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
shiki:
|
||||
specifier: ^3.0.0
|
||||
version: 3.21.0
|
||||
zod:
|
||||
specifier: ^4.0.0
|
||||
version: 4.3.5
|
||||
@@ -179,6 +197,22 @@ importers:
|
||||
specifier: ^5.7.2
|
||||
version: 5.9.2
|
||||
|
||||
packages/codegen:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
devDependencies:
|
||||
'@repo/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
tsup:
|
||||
specifier: ^8.0.2
|
||||
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.2
|
||||
|
||||
packages/core:
|
||||
dependencies:
|
||||
zod:
|
||||
@@ -1393,6 +1427,18 @@ packages:
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@upstash/ratelimit@2.0.8':
|
||||
resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==}
|
||||
peerDependencies:
|
||||
'@upstash/redis': ^1.34.3
|
||||
|
||||
'@upstash/redis@1.36.1':
|
||||
resolution: {integrity: sha512-N6SjDcgXdOcTAF+7uNoY69o7hCspe9BcA7YjQdxVu5d25avljTwyLaHBW3krWjrP0FfocgMk94qyVtQbeDp39A==}
|
||||
|
||||
'@vercel/analytics@1.6.1':
|
||||
resolution: {integrity: sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==}
|
||||
peerDependencies:
|
||||
@@ -2675,6 +2721,12 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-resizable-panels@4.4.1:
|
||||
resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3093,6 +3145,9 @@ packages:
|
||||
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
uncrypto@0.1.3:
|
||||
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
@@ -4207,6 +4262,19 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.0': {}
|
||||
|
||||
'@upstash/core-analytics@0.0.10':
|
||||
dependencies:
|
||||
'@upstash/redis': 1.36.1
|
||||
|
||||
'@upstash/ratelimit@2.0.8(@upstash/redis@1.36.1)':
|
||||
dependencies:
|
||||
'@upstash/core-analytics': 0.0.10
|
||||
'@upstash/redis': 1.36.1
|
||||
|
||||
'@upstash/redis@1.36.1':
|
||||
dependencies:
|
||||
uncrypto: 0.1.3
|
||||
|
||||
'@vercel/analytics@1.6.1(next@16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)':
|
||||
optionalDependencies:
|
||||
next: 16.1.1(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -5596,6 +5664,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.3
|
||||
|
||||
react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.3)(react@19.2.3):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -6115,6 +6188,8 @@ snapshots:
|
||||
has-symbols: 1.1.0
|
||||
which-boxed-primitive: 1.1.1
|
||||
|
||||
uncrypto@0.1.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"$schema": "https://turborepo.dev/schema.json",
|
||||
"ui": "tui",
|
||||
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
|
||||
"tasks": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user