mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06859dee58 | ||
|
|
6852a93606 | ||
|
|
23b5d6b750 | ||
|
|
41d437cce7 |
@@ -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: [
|
items: [
|
||||||
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
|
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
|
||||||
{ title: "Streaming", href: "/docs/streaming" },
|
{ title: "Streaming", href: "/docs/streaming" },
|
||||||
|
{ title: "Code Export", href: "/docs/code-export" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,7 @@ const navigation = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||||
|
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+94
-4
@@ -150,6 +150,96 @@ export const catalog = createCatalog({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Code Export */}
|
||||||
|
<section className="border-t border-border">
|
||||||
|
<div className="max-w-5xl mx-auto px-6 py-24">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-2xl font-semibold mb-4">Export as Code</h2>
|
||||||
|
<p className="text-muted-foreground max-w-2xl mx-auto">
|
||||||
|
Export generated UI as standalone React components. No runtime
|
||||||
|
dependencies required.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid lg:grid-cols-2 gap-12">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Generated UI Tree</h3>
|
||||||
|
<p className="text-muted-foreground mb-6 text-sm">
|
||||||
|
AI generates a JSON structure from the user's prompt.
|
||||||
|
</p>
|
||||||
|
<Code lang="json">{`{
|
||||||
|
"root": "card",
|
||||||
|
"elements": {
|
||||||
|
"card": {
|
||||||
|
"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 */}
|
{/* Features */}
|
||||||
<section className="border-t border-border">
|
<section className="border-t border-border">
|
||||||
<div className="max-w-5xl mx-auto px-6 py-24">
|
<div className="max-w-5xl mx-auto px-6 py-24">
|
||||||
@@ -164,6 +254,10 @@ export const catalog = createCatalog({
|
|||||||
title: "Streaming",
|
title: "Streaming",
|
||||||
desc: "Progressive rendering as JSON streams from the model",
|
desc: "Progressive rendering as JSON streams from the model",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Code Export",
|
||||||
|
desc: "Export as standalone React code with no runtime dependencies",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Data Binding",
|
title: "Data Binding",
|
||||||
desc: "Two-way binding with JSON Pointer paths",
|
desc: "Two-way binding with JSON Pointer paths",
|
||||||
@@ -176,10 +270,6 @@ export const catalog = createCatalog({
|
|||||||
title: "Visibility",
|
title: "Visibility",
|
||||||
desc: "Conditional show/hide based on data or auth",
|
desc: "Conditional show/hide based on data or auth",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "Validation",
|
|
||||||
desc: "Built-in and custom validation functions",
|
|
||||||
},
|
|
||||||
].map((feature) => (
|
].map((feature) => (
|
||||||
<div key={feature.title}>
|
<div key={feature.title}>
|
||||||
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
<h3 className="font-semibold mb-2">{feature.title}</h3>
|
||||||
|
|||||||
@@ -159,9 +159,16 @@ if (typeof window !== "undefined") {
|
|||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang: "json" | "tsx" | "typescript";
|
lang: "json" | "tsx" | "typescript";
|
||||||
|
fillHeight?: boolean;
|
||||||
|
hideCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CodeBlock({ code, lang }: CodeBlockProps) {
|
export function CodeBlock({
|
||||||
|
code,
|
||||||
|
lang,
|
||||||
|
fillHeight,
|
||||||
|
hideCopyButton,
|
||||||
|
}: CodeBlockProps) {
|
||||||
const [html, setHtml] = useState<string>("");
|
const [html, setHtml] = useState<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -180,19 +187,21 @@ export function CodeBlock({ code, lang }: CodeBlockProps) {
|
|||||||
}, [code, lang]);
|
}, [code, lang]);
|
||||||
|
|
||||||
if (!html) {
|
if (!html) {
|
||||||
return null;
|
return fillHeight ? <div className="p-3" /> : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative group">
|
<div className={`relative group ${fillHeight ? "p-3" : ""}`}>
|
||||||
<div className="sticky top-0 float-right z-10">
|
{!hideCopyButton && (
|
||||||
<CopyButton
|
<div className="float-right sticky top-3 z-10 ml-2">
|
||||||
text={code}
|
<CopyButton
|
||||||
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
text={code}
|
||||||
/>
|
className="opacity-0 group-hover:opacity-100 text-neutral-400"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
|
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1082
-79
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/gateway": "^3.0.13",
|
"@ai-sdk/gateway": "^3.0.13",
|
||||||
|
"@json-render/codegen": "workspace:*",
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
"@json-render/react": "workspace:*",
|
"@json-render/react": "workspace:*",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
|||||||
+266
-15
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useCallback } from "react";
|
import { useState, useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
DataProvider,
|
DataProvider,
|
||||||
ActionProvider,
|
ActionProvider,
|
||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
Renderer,
|
Renderer,
|
||||||
} from "@json-render/react";
|
} from "@json-render/react";
|
||||||
import { componentRegistry } from "@/components/ui";
|
import { componentRegistry } from "@/components/ui";
|
||||||
|
import { generateNextJSProject } from "@/lib/codegen";
|
||||||
|
import {
|
||||||
|
CodeHighlight,
|
||||||
|
getLanguageFromPath,
|
||||||
|
} from "@/components/code-highlight";
|
||||||
|
|
||||||
const INITIAL_DATA = {
|
const INITIAL_DATA = {
|
||||||
analytics: {
|
analytics: {
|
||||||
@@ -69,11 +74,24 @@ const ACTION_HANDLERS = {
|
|||||||
|
|
||||||
function DashboardContent() {
|
function DashboardContent() {
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [showCodeExport, setShowCodeExport] = useState(false);
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const { tree, isStreaming, error, send, clear } = useUIStream({
|
const { tree, isStreaming, error, send, clear } = useUIStream({
|
||||||
api: "/api/generate",
|
api: "/api/generate",
|
||||||
onError: (err) => console.error("Generation error:", err),
|
onError: (err) => console.error("Generation error:", err),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exportedFiles = useMemo(
|
||||||
|
() =>
|
||||||
|
tree
|
||||||
|
? generateNextJSProject(tree, {
|
||||||
|
projectName: "my-dashboard",
|
||||||
|
data: INITIAL_DATA,
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
[tree],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (e: React.FormEvent) => {
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -83,6 +101,26 @@ function DashboardContent() {
|
|||||||
[prompt, send],
|
[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 = [
|
const examples = [
|
||||||
"Revenue dashboard with metrics and chart",
|
"Revenue dashboard with metrics and chart",
|
||||||
"Recent transactions table",
|
"Recent transactions table",
|
||||||
@@ -91,6 +129,13 @@ function DashboardContent() {
|
|||||||
|
|
||||||
const hasElements = tree && Object.keys(tree.elements).length > 0;
|
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 (
|
return (
|
||||||
<div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
|
<div style={{ maxWidth: 960, margin: "0 auto", padding: "48px 24px" }}>
|
||||||
<header style={{ marginBottom: 48 }}>
|
<header style={{ marginBottom: 48 }}>
|
||||||
@@ -145,20 +190,39 @@ function DashboardContent() {
|
|||||||
{isStreaming ? "Generating..." : "Generate"}
|
{isStreaming ? "Generating..." : "Generate"}
|
||||||
</button>
|
</button>
|
||||||
{hasElements && (
|
{hasElements && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<button
|
||||||
onClick={clear}
|
type="button"
|
||||||
style={{
|
onClick={() => {
|
||||||
padding: "12px 16px",
|
setSelectedFile(null);
|
||||||
background: "transparent",
|
setShowCodeExport(true);
|
||||||
color: "var(--muted)",
|
}}
|
||||||
border: "1px solid var(--border)",
|
style={{
|
||||||
borderRadius: "var(--radius)",
|
padding: "12px 16px",
|
||||||
fontSize: 16,
|
background: "transparent",
|
||||||
}}
|
color: "var(--foreground)",
|
||||||
>
|
border: "1px solid var(--border)",
|
||||||
Clear
|
borderRadius: "var(--radius)",
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -250,6 +314,193 @@ function DashboardContent() {
|
|||||||
</pre>
|
</pre>
|
||||||
</details>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
};
|
||||||
@@ -12,11 +12,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/gateway": "^3.0.13",
|
"@ai-sdk/gateway": "^3.0.13",
|
||||||
"ai": "^6.0.33",
|
"ai": "^6.0.33",
|
||||||
|
"@json-render/codegen": "workspace:*",
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
"@json-render/react": "workspace:*",
|
"@json-render/react": "workspace:*",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"shiki": "^3.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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"],
|
||||||
|
});
|
||||||
Generated
+25
@@ -41,6 +41,9 @@ importers:
|
|||||||
'@ai-sdk/gateway':
|
'@ai-sdk/gateway':
|
||||||
specifier: ^3.0.13
|
specifier: ^3.0.13
|
||||||
version: 3.0.13(zod@4.3.5)
|
version: 3.0.13(zod@4.3.5)
|
||||||
|
'@json-render/codegen':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/codegen
|
||||||
'@json-render/core':
|
'@json-render/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core
|
version: link:../../packages/core
|
||||||
@@ -138,6 +141,9 @@ importers:
|
|||||||
'@ai-sdk/gateway':
|
'@ai-sdk/gateway':
|
||||||
specifier: ^3.0.13
|
specifier: ^3.0.13
|
||||||
version: 3.0.13(zod@4.3.5)
|
version: 3.0.13(zod@4.3.5)
|
||||||
|
'@json-render/codegen':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/codegen
|
||||||
'@json-render/core':
|
'@json-render/core':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/core
|
version: link:../../packages/core
|
||||||
@@ -156,6 +162,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.3
|
specifier: 19.2.3
|
||||||
version: 19.2.3(react@19.2.3)
|
version: 19.2.3(react@19.2.3)
|
||||||
|
shiki:
|
||||||
|
specifier: ^3.0.0
|
||||||
|
version: 3.21.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.3.5
|
version: 4.3.5
|
||||||
@@ -179,6 +188,22 @@ importers:
|
|||||||
specifier: ^5.7.2
|
specifier: ^5.7.2
|
||||||
version: 5.9.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:
|
packages/core:
|
||||||
dependencies:
|
dependencies:
|
||||||
zod:
|
zod:
|
||||||
|
|||||||
Reference in New Issue
Block a user