Compare commits

..
Author SHA1 Message Date
Chris Tate 957045c6b8 fix ge build 2026-03-20 21:46:33 -05:00
368 changed files with 245 additions and 19974 deletions
+1 -4
View File
@@ -22,10 +22,7 @@
"@json-render/svelte",
"@json-render/solid",
"@json-render/react-three-fiber",
"@json-render/yaml",
"@json-render/shadcn-svelte",
"@json-render/ink",
"@json-render/next"
"@json-render/yaml"
]
],
"linked": [],
-122
View File
@@ -23,10 +23,6 @@ npm install @json-render/core @json-render/vue
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
# or for terminal UIs
npm install @json-render/core @json-render/ink ink react
# or for full Next.js apps (routes, layouts, SSR, metadata)
npm install @json-render/core @json-render/react @json-render/next
# or for 3D scenes
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
```
@@ -127,14 +123,11 @@ function Dashboard({ spec }) {
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/shadcn-svelte`| 36 pre-built shadcn-svelte components (Svelte 5 + Tailwind CSS) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
| `@json-render/react-native` | React Native renderer with standard mobile components |
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/ink` | Ink terminal renderer with built-in components for interactive TUIs. |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
@@ -486,121 +479,6 @@ const { registry } = defineRegistry(catalog, {
/>;
```
### Next.js (Full Apps)
```typescript
import type { NextAppSpec } from "@json-render/next";
import { createNextApp } from "@json-render/next/server";
import { NextAppProvider } from "@json-render/next";
const spec: NextAppSpec = {
metadata: { title: { default: "My App", template: "%s | My App" } },
layouts: {
main: {
root: "shell",
elements: {
shell: { type: "Container", props: {}, children: ["nav", "slot"] },
nav: { type: "NavBar", props: {}, children: [] },
slot: { type: "Slot", props: {}, children: [] },
},
},
},
routes: {
"/": {
layout: "main",
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
},
},
},
},
};
// Server: creates Page, generateMetadata, generateStaticParams
const app = createNextApp({ spec });
// Client: wrap your layout with NextAppProvider
// <NextAppProvider registry={registry} handlers={handlers}>
// {children}
// </NextAppProvider>
```
### shadcn-svelte (Svelte)
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/svelte/schema";
import { defineRegistry, Renderer } from "@json-render/svelte";
import { shadcnComponentDefinitions } from "@json-render/shadcn-svelte/catalog";
import { shadcnComponents } from "@json-render/shadcn-svelte";
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
// In your Svelte component:
// <Renderer spec={spec} registry={registry} />
```
### Ink (Terminal)
```tsx
import { defineCatalog } from "@json-render/core";
import {
schema,
standardComponentDefinitions,
standardActionDefinitions,
defineRegistry,
Renderer,
JSONUIProvider,
} from "@json-render/ink";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Status" },
children: ["status-1"],
},
"status-1": {
type: "StatusLine",
props: { label: "Build", status: "success" },
children: [],
},
},
};
<JSONUIProvider initialState={{}}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>;
```
## Features
### Streaming (SpecStream)
-20
View File
@@ -1,25 +1,5 @@
# web
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/codegen@0.16.0
- @json-render/react@0.16.0
- @json-render/yaml@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/codegen@0.15.0
- @json-render/react@0.15.0
- @json-render/yaml@0.15.0
## 0.1.9
### Patch Changes
-293
View File
@@ -1,293 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/ink")
# @json-render/ink
Terminal renderer for [Ink](https://github.com/vadimdemedes/ink) with multiple standard components, providers, hooks, and streaming support.
## Installation
<PackageInstall packages="@json-render/core @json-render/ink" />
Peer dependencies: `react ^18.0.0 || ^19.0.0`, `ink ^6.0.0`, and `zod ^4.0.0`.
<PackageInstall packages="react ink zod" />
## Standard Components
### Layout
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Box</code></td><td><code>flexDirection</code>, <code>alignItems</code>, <code>justifyContent</code>, <code>gap</code>, <code>padding</code>, <code>margin</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>width</code>, <code>height</code>, <code>display</code>, <code>overflow</code></td><td>Flexbox layout container (like a terminal div)</td></tr>
<tr><td><code>Spacer</code></td><td>(none)</td><td>Flexible empty space that expands to fill available room</td></tr>
<tr><td><code>Newline</code></td><td><code>count</code></td><td>Insert blank lines</td></tr>
</tbody>
</table>
### Content
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Text</code></td><td><code>text</code>, <code>color</code>, <code>bold</code>, <code>italic</code>, <code>underline</code>, <code>strikethrough</code>, <code>dimColor</code>, <code>inverse</code>, <code>wrap</code></td><td>Text output with styling</td></tr>
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code> (h1-h4), <code>color</code></td><td>Section heading</td></tr>
<tr><td><code>Divider</code></td><td><code>character</code>, <code>color</code>, <code>dimColor</code>, <code>title</code>, <code>width</code></td><td>Horizontal separator line with optional title</td></tr>
<tr><td><code>Badge</code></td><td><code>label</code>, <code>variant</code></td><td>Colored inline label (default, info, success, warning, error)</td></tr>
<tr><td><code>Spinner</code></td><td><code>label</code>, <code>color</code></td><td>Animated loading spinner</td></tr>
<tr><td><code>ProgressBar</code></td><td><code>progress</code> (0-1), <code>width</code>, <code>color</code>, <code>label</code></td><td>Horizontal progress bar</td></tr>
<tr><td><code>StatusLine</code></td><td><code>text</code>, <code>status</code>, <code>icon</code></td><td>Status message with colored icon</td></tr>
<tr><td><code>KeyValue</code></td><td><code>label</code>, <code>value</code>, <code>labelColor</code>, <code>separator</code></td><td>Key-value pair display</td></tr>
<tr><td><code>Link</code></td><td><code>url</code>, <code>label</code>, <code>color</code></td><td>Renders a URL as underlined text. Shows "label (url)" when label is provided.</td></tr>
<tr><td><code>Markdown</code></td><td><code>text</code></td><td>Renders markdown with terminal styling (headings, bold, italic, code, lists, blockquotes, horizontal rules)</td></tr>
</tbody>
</table>
### Data
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>Table</code></td><td><code>columns</code>, <code>rows</code>, <code>borderStyle</code>, <code>headerColor</code></td><td>Tabular data with headers</td></tr>
<tr><td><code>List</code></td><td><code>items</code>, <code>ordered</code>, <code>bulletChar</code>, <code>spacing</code></td><td>Bulleted or numbered list</td></tr>
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code></td><td>Structured list row</td></tr>
<tr><td><code>Card</code></td><td><code>title</code>, <code>borderStyle</code>, <code>borderColor</code>, <code>padding</code></td><td>Bordered container with optional title</td></tr>
<tr><td><code>Sparkline</code></td><td><code>data</code>, <code>width</code>, <code>color</code>, <code>label</code>, <code>min</code>, <code>max</code></td><td>Inline sparkline chart using Unicode blocks (▁▂▃▄▅▆▇█)</td></tr>
<tr><td><code>BarChart</code></td><td><code>data</code> (label/value/color), <code>width</code>, <code>showValues</code>, <code>showPercentage</code></td><td>Horizontal bar chart for comparing values</td></tr>
</tbody>
</table>
### Interactive
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>mask</code></td><td>Text input field. Press Enter to submit.</td></tr>
<tr><td><code>Select</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code></td><td>Arrow-key selection menu</td></tr>
<tr><td><code>MultiSelect</code></td><td><code>options</code>, <code>value</code> (use <code>$bindState</code>), <code>label</code>, <code>min</code>, <code>max</code></td><td>Multi-selection menu. Space to toggle, Enter to confirm.</td></tr>
<tr><td><code>ConfirmInput</code></td><td><code>message</code>, <code>defaultValue</code>, <code>yesLabel</code>, <code>noLabel</code></td><td>Yes/No confirmation prompt. Press Y or N.</td></tr>
<tr><td><code>Tabs</code></td><td><code>tabs</code>, <code>value</code> (use <code>$bindState</code>), <code>color</code></td><td>Tab bar navigation with left/right arrow keys. Place child content inside with visible conditions.</td></tr>
</tbody>
</table>
## Providers
### JSONUIProvider
Convenience wrapper around all providers: `StateProvider` → `VisibilityProvider` → `ValidationProvider` → `ActionProvider` → `FocusProvider`.
```tsx
import { JSONUIProvider, Renderer } from "@json-render/ink";
<JSONUIProvider initialState={{}} handlers={handlers}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>
```
### StateProvider
```tsx
<StateProvider initialState={object} onStateChange={fn}>
{children}
</StateProvider>
```
<table>
<thead>
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
<tr><td><code>initialState</code></td><td><code>Record&lt;string, unknown&gt;</code></td><td>Initial state model (uncontrolled mode).</td></tr>
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode).</td></tr>
</tbody>
</table>
#### External Store (Controlled Mode)
Pass a `StateStore` to bypass internal state and wire json-render to any state management:
```tsx
import { createStateStore } from "@json-render/ink";
const store = createStateStore({ count: 0 });
<StateProvider store={store}>
{children}
</StateProvider>
// Mutate from anywhere — components re-render automatically:
store.set("/count", 1);
```
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
### ActionProvider
```tsx
<ActionProvider handlers={Record<string, ActionHandler>} navigate={fn}>
{children}
</ActionProvider>
```
Built-in actions: `setState`, `pushState`, `removeState`, `log`, `exit`. Custom handlers override built-ins. Includes a terminal confirmation dialog (press Y/N) for actions with `confirm`.
### VisibilityProvider
```tsx
<VisibilityProvider>
{children}
</VisibilityProvider>
```
### ValidationProvider
```tsx
<ValidationProvider>
{children}
</ValidationProvider>
```
### FocusProvider
```tsx
<FocusProvider>
{children}
</FocusProvider>
```
Manages Tab-cycling focus between interactive components (TextInput, Select). Supports `useFocusDisable` to suppress cycling during modal dialogs.
## defineRegistry
Create a type-safe component registry. Standard components are built-in; only register custom components.
```tsx
import { defineRegistry, type Components } from "@json-render/ink";
const { registry, handlers, executeAction } = defineRegistry(catalog, {
components: {
MyWidget: ({ props }) => <Text>{props.label}</Text>,
} as Components<typeof catalog>,
actions: {
submit: async (params, setState, state) => {
// custom action logic
},
},
});
```
`handlers` is designed for `JSONUIProvider`/`ActionProvider`. `executeAction` is an imperative helper.
## createRenderer
Higher-level helper that wraps `Renderer` + all providers into a single component.
```tsx
import { createRenderer } from "@json-render/ink";
const UIRenderer = createRenderer(catalog, components);
<UIRenderer spec={spec} state={initialState} />;
```
## Hooks
### useUIStream
```typescript
const {
spec, // Spec | null - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
stop, // () => void - abort the current stream
clear, // () => void - reset spec and error
} = useUIStream({
api: string,
onComplete?: (spec: Spec) => void,
onError?: (error: Error) => void,
fetch?: (url: string, init?: RequestInit) => Promise<Response>,
validate?: boolean,
maxRetries?: number,
});
```
### useStateStore
```typescript
const { state, get, set, update } = useStateStore();
```
### useStateValue
```typescript
const value = useStateValue(path: string);
```
### useBoundProp
```typescript
const [value, setValue] = useBoundProp(resolvedValue, bindingPath);
```
### useActions
```typescript
const { execute } = useActions();
```
### useIsVisible
```typescript
const isVisible = useIsVisible(condition?: VisibilityCondition);
```
### useFocus
```typescript
const { isActive, id } = useFocus();
```
### useFocusDisable
```typescript
useFocusDisable(disabled: boolean);
```
Suppresses Tab-cycling while `disabled` is true (e.g., during a modal dialog).
## Catalog Exports
```typescript
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/catalog";
import { schema } from "@json-render/ink/schema";
```
<table>
<thead>
<tr><th>Export</th><th>Purpose</th></tr>
</thead>
<tbody>
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 19 standard components</td></tr>
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, pushState, removeState, log, exit)</td></tr>
<tr><td><code>schema</code></td><td>Ink element tree schema</td></tr>
</tbody>
</table>
## Server Export
```typescript
import { schema, standardComponentDefinitions, standardActionDefinitions } from "@json-render/ink/server";
```
Re-exports the schema and catalog definitions for server-side usage (e.g., building system prompts).
-280
View File
@@ -1,280 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/next")
# @json-render/next
Next.js renderer. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
## Installation
```bash
npm install @json-render/core @json-render/react @json-render/next
```
## schema
The Next.js app schema for multi-page specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/next/server';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
description: 'Card container',
},
NavBar: {
props: z.object({ links: z.array(z.object({ href: z.string(), label: z.string() })) }),
description: 'Navigation bar',
},
},
actions: {},
});
```
## createNextApp
Create all exports needed for a Next.js `[[...slug]]` catch-all route.
```typescript
import { createNextApp } from '@json-render/next/server';
const { Page, generateMetadata, generateStaticParams } = createNextApp({
spec: myAppSpec,
loaders: {
loadPost: async ({ slug }) => {
const post = await db.post.findUnique({ where: { slug } });
return { post };
},
},
});
```
### Options
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>spec</code></td>
<td><code>{'NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>)'}</code></td>
<td>The application spec (static or dynamic)</td>
</tr>
<tr>
<td><code>loaders</code></td>
<td><code>{'Record<string, LoaderFn>'}</code></td>
<td>Server-side data loaders keyed by name</td>
</tr>
</tbody>
</table>
### Returns
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Page</code></td>
<td>Async Server Component for <code>page.tsx</code></td>
</tr>
<tr>
<td><code>generateMetadata</code></td>
<td>Metadata generator for Next.js SEO</td>
</tr>
<tr>
<td><code>generateStaticParams</code></td>
<td>Static params for pre-rendering at build time</td>
</tr>
</tbody>
</table>
## NextAppSpec
The top-level spec defining an entire Next.js application.
```typescript
interface NextAppSpec {
metadata?: NextMetadata;
routes: Record<string, NextRouteSpec>;
layouts?: Record<string, Spec>;
state?: Record<string, unknown>;
}
```
### Route Patterns
Routes use Next.js URL conventions:
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Example Match</th>
<th>Params</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>/</code></td>
<td><code>/</code></td>
<td><code>{'{}'}</code></td>
</tr>
<tr>
<td><code>/about</code></td>
<td><code>/about</code></td>
<td><code>{'{}'}</code></td>
</tr>
<tr>
<td><code>/blog/[slug]</code></td>
<td><code>/blog/hello</code></td>
<td><code>{'{ slug: "hello" }'}</code></td>
</tr>
<tr>
<td><code>/docs/[...path]</code></td>
<td><code>/docs/a/b/c</code></td>
<td><code>{'{ path: ["a","b","c"] }'}</code></td>
</tr>
<tr>
<td><code>/app/[[...path]]</code></td>
<td><code>/app</code> or <code>/app/x/y</code></td>
<td><code>{'{ path: [] }'}</code> or <code>{'{ path: ["x","y"] }'}</code></td>
</tr>
</tbody>
</table>
## NextAppProvider
Client component that provides the component registry and action handlers to all pages.
```tsx
import { NextAppProvider } from '@json-render/next';
export default function Layout({ children }) {
return (
<NextAppProvider registry={registry} handlers={handlers}>
{children}
</NextAppProvider>
);
}
```
## Built-in Components
### Slot
Placeholder in layouts where page content is rendered. Every layout MUST include a Slot.
```json
{ "type": "Slot", "props": {}, "children": [] }
```
### Link
Client-side navigation wrapping `next/link`.
```json
{ "type": "Link", "props": { "href": "/about" }, "children": ["link-text"] }
```
## Built-in Actions
<table>
<thead>
<tr>
<th>Action</th>
<th>Params</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>setState</code></td>
<td><code>{'{ statePath, value }'}</code></td>
<td>Update a value in state</td>
</tr>
<tr>
<td><code>pushState</code></td>
<td><code>{'{ statePath, value, clearStatePath? }'}</code></td>
<td>Append to array in state</td>
</tr>
<tr>
<td><code>removeState</code></td>
<td><code>{'{ statePath, index }'}</code></td>
<td>Remove from array by index</td>
</tr>
<tr>
<td><code>navigate</code></td>
<td><code>{'{ href }'}</code></td>
<td>Client-side navigation</td>
</tr>
</tbody>
</table>
## Server Utilities
### matchRoute
Match a pathname against a spec's routes.
```typescript
import { matchRoute } from '@json-render/next/server';
const matched = matchRoute(spec, '/blog/hello-world');
// { route: NextRouteSpec, pattern: '/blog/[slug]', params: { slug: 'hello-world' } }
```
### resolveMetadata
Resolve merged metadata for a route.
```typescript
import { resolveMetadata } from '@json-render/next/server';
const metadata = resolveMetadata(spec, matchedRoute?.route);
```
### slugToPath
Convert catch-all slug array to pathname.
```typescript
import { slugToPath } from '@json-render/next/server';
slugToPath(undefined); // "/"
slugToPath(['blog', 'hello']); // "/blog/hello"
```
## Entry Points
<table>
<thead>
<tr>
<th>Import</th>
<th>Contents</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/next</code></td>
<td>Client components (NextAppProvider, PageRenderer, Link)</td>
</tr>
<tr>
<td><code>@json-render/next/server</code></td>
<td>Server utilities (createNextApp, matchRoute, schema)</td>
</tr>
</tbody>
</table>
@@ -1,320 +0,0 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/shadcn-svelte")
# @json-render/shadcn-svelte
Pre-built [shadcn-svelte](https://www.shadcn-svelte.com/) components for json-render. 36 components built on Svelte 5 + Tailwind CSS, ready to use with `defineCatalog` and `defineRegistry`.
## Installation
```bash
npm install @json-render/shadcn-svelte @json-render/core @json-render/svelte zod
```
Your app must have Tailwind CSS configured.
## Entry Points
<table>
<thead>
<tr>
<th>Entry Point</th>
<th>Exports</th>
<th>Use For</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/shadcn-svelte</code></td>
<td><code>shadcnComponents</code>, <code>shadcnComponentDefinitions</code></td>
<td>Svelte implementations + catalog schemas</td>
</tr>
<tr>
<td><code>@json-render/shadcn-svelte/catalog</code></td>
<td><code>shadcnComponentDefinitions</code></td>
<td>Catalog schemas only (no Svelte dependency, safe for server)</td>
</tr>
</tbody>
</table>
## Usage
Pick the components you need from the standard definitions:
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/svelte/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn-svelte/catalog";
import { defineRegistry } from "@json-render/svelte";
import { shadcnComponents } from "@json-render/shadcn-svelte";
// Catalog: pick definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
Input: shadcnComponentDefinitions.Input,
},
actions: {},
});
// Registry: pick matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
Input: shadcnComponents.Input,
},
});
```
Then render in your Svelte component:
```svelte
<script lang="ts">
import { Renderer, JsonUIProvider } from "@json-render/svelte";
export let spec;
export let registry;
</script>
<JsonUIProvider initialState={spec?.state ?? {}}>
<Renderer {spec} {registry} />
</JsonUIProvider>
```
## Available Components
### Layout
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Card</code></td>
<td>Container card with optional title, description, maxWidth, centered</td>
</tr>
<tr>
<td><code>Stack</code></td>
<td>Flex container with direction, gap, align, justify</td>
</tr>
<tr>
<td><code>Grid</code></td>
<td>Grid layout with columns (1-6) and gap</td>
</tr>
<tr>
<td><code>Separator</code></td>
<td>Visual separator line with orientation</td>
</tr>
</tbody>
</table>
### Navigation
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Tabs</code></td>
<td>Tabbed navigation with tabs array, defaultValue, value</td>
</tr>
<tr>
<td><code>Accordion</code></td>
<td>Collapsible sections with items array and type (single/multiple)</td>
</tr>
<tr>
<td><code>Collapsible</code></td>
<td>Single collapsible section with title and defaultOpen</td>
</tr>
<tr>
<td><code>Pagination</code></td>
<td>Page navigation with totalPages and page</td>
</tr>
</tbody>
</table>
### Overlay
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Dialog</code></td>
<td>Modal dialog with title, description, openPath</td>
</tr>
<tr>
<td><code>Drawer</code></td>
<td>Bottom drawer with title, description, openPath</td>
</tr>
<tr>
<td><code>Tooltip</code></td>
<td>Hover tooltip with content and text</td>
</tr>
<tr>
<td><code>Popover</code></td>
<td>Click-triggered popover with trigger and content</td>
</tr>
<tr>
<td><code>DropdownMenu</code></td>
<td>Dropdown menu with label and items array</td>
</tr>
</tbody>
</table>
### Content
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Heading</code></td>
<td>Heading text with level (h1-h4)</td>
</tr>
<tr>
<td><code>Text</code></td>
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
</tr>
<tr>
<td><code>Image</code></td>
<td>Image with alt, width, height</td>
</tr>
<tr>
<td><code>Avatar</code></td>
<td>User avatar with src, name, size</td>
</tr>
<tr>
<td><code>Badge</code></td>
<td>Status badge with text and variant</td>
</tr>
<tr>
<td><code>Alert</code></td>
<td>Alert banner with title, message, type</td>
</tr>
<tr>
<td><code>Carousel</code></td>
<td>Horizontally scrollable carousel with items</td>
</tr>
<tr>
<td><code>Table</code></td>
<td>Data table with columns and rows</td>
</tr>
</tbody>
</table>
### Feedback
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Progress</code></td>
<td>Progress bar with value, max, label</td>
</tr>
<tr>
<td><code>Skeleton</code></td>
<td>Loading placeholder with width, height, rounded</td>
</tr>
<tr>
<td><code>Spinner</code></td>
<td>Loading spinner with size and label</td>
</tr>
</tbody>
</table>
### Input
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Button</code></td>
<td>Clickable button with label, variant, disabled</td>
</tr>
<tr>
<td><code>Link</code></td>
<td>Anchor link with label and href</td>
</tr>
<tr>
<td><code>Input</code></td>
<td>Text input with label, name, type, placeholder, value, checks</td>
</tr>
<tr>
<td><code>Textarea</code></td>
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
</tr>
<tr>
<td><code>Select</code></td>
<td>Dropdown select with label, name, options, value, checks</td>
</tr>
<tr>
<td><code>Checkbox</code></td>
<td>Checkbox with label, name, checked</td>
</tr>
<tr>
<td><code>Radio</code></td>
<td>Radio button group with label, name, options, value</td>
</tr>
<tr>
<td><code>Switch</code></td>
<td>Toggle switch with label, name, checked</td>
</tr>
<tr>
<td><code>Slider</code></td>
<td>Range slider with label, min, max, step, value</td>
</tr>
<tr>
<td><code>Toggle</code></td>
<td>Toggle button with label, pressed, variant</td>
</tr>
<tr>
<td><code>ToggleGroup</code></td>
<td>Group of toggle buttons with items, type, value</td>
</tr>
<tr>
<td><code>ButtonGroup</code></td>
<td>Group of buttons with buttons array and selected</td>
</tr>
</tbody>
</table>
## Notes
- The `/catalog` entry point has no Svelte dependency -- use it for server-side prompt generation
- Components use Tailwind CSS classes -- your app must have Tailwind configured
- Component implementations use bundled shadcn-svelte primitives (not your app's `$lib/components/ui/`)
- Form inputs support `checks` for validation (type + message pairs) and `validateOn` for timing
- Events: inputs emit `change`/`submit`/`focus`/`blur`; buttons emit `press`; selects emit `change`/`select`
@@ -83,13 +83,6 @@ All renderers share the same workflow:
</td>
<td>Video compositions</td>
</tr>
<tr>
<td>Ink</td>
<td>
<code>@json-render/ink</code>
</td>
<td>Terminal UI (via Ink)</td>
</tr>
</tbody>
</table>
@@ -270,30 +263,6 @@ Uses a timeline spec format with compositions, tracks, and clips. Includes stand
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
## Ink (Terminal)
Render specs as terminal UIs using [Ink](https://github.com/vadimdemedes/ink). Multiple standard components including tables, progress bars, spinners, tabs, multi-select, and interactive inputs with Tab-cycling focus.
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/ink/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/ink/catalog";
import { defineRegistry, Renderer } from "@json-render/ink";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />;
```
See the [@json-render/ink API reference](/docs/api/ink) for details.
## Custom Renderers
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/shadcn-svelte, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
-3
View File
@@ -69,15 +69,12 @@ export const docsNavigation: NavSection[] = [
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/next", href: "/docs/api/next" },
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/shadcn-svelte", href: "/docs/api/shadcn-svelte" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/image", href: "/docs/api/image" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
{ title: "@json-render/ink", href: "/docs/api/ink" },
{ title: "@json-render/vue", href: "/docs/api/vue" },
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
{ title: "@json-render/solid", href: "/docs/api/solid" },
-8
View File
@@ -133,14 +133,6 @@ export const examples: Example[] = [
githubPath: "examples/image",
demoUrl: "https://image-demo.json-render.dev",
},
{
slug: "ink-chat",
title: "Ink Chat",
description:
"Terminal chat agent that streams rich json-render UIs using Ink and the AI Gateway.",
tags: ["Ink", "Terminal", "AI"],
githubPath: "examples/ink-chat",
},
{
slug: "mcp",
title: "MCP App",
-3
View File
@@ -43,7 +43,6 @@ export const PAGE_TITLES: Record<string, string> = {
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/next": "@json-render/next API",
"docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API",
@@ -54,14 +53,12 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
"docs/api/shadcn-svelte": "@json-render/shadcn-svelte API",
"docs/api/mcp": "@json-render/mcp API",
"docs/api/redux": "@json-render/redux API",
"docs/api/zustand": "@json-render/zustand API",
"docs/api/jotai": "@json-render/jotai API",
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
"docs/api/xstate": "@json-render/xstate API",
"docs/api/ink": "@json-render/ink API",
"docs/api/yaml": "@json-render/yaml API",
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"license": "Apache-2.0",
-18
View File
@@ -1,23 +1,5 @@
# example-chat
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
- @json-render/shadcn@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
- @json-render/shadcn@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-18
View File
@@ -1,23 +1,5 @@
# example-dashboard
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/codegen@0.16.0
- @json-render/react@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/codegen@0.15.0
- @json-render/react@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-21
View File
@@ -1,21 +0,0 @@
# example-game-engine
## 0.1.2
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
- @json-render/react-three-fiber@0.16.0
- @json-render/yaml@0.16.0
## 0.1.1
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
- @json-render/react-three-fiber@0.15.0
- @json-render/yaml@0.15.0
@@ -590,8 +590,7 @@ export function GameExtrude({
>
<extrudeGeometry
args={[
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
shape as any,
shape as THREE.Shape,
{ depth: depth ?? 1, bevelEnabled: false },
]}
/>
@@ -643,8 +642,7 @@ export function GameTube({
>
<tubeGeometry
args={[
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
curve as any,
curve as THREE.Curve<THREE.Vector3>,
tubularSegments ?? 64,
radius ?? 0.1,
radialSegments ?? 8,
@@ -716,8 +714,7 @@ export function GameShape({
castShadow={castShadow ?? false}
receiveShadow={receiveShadow ?? false}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch */}
<shapeGeometry args={[shape as any]} />
<shapeGeometry args={[shape as THREE.Shape]} />
<meshStandardMaterial
{...buildMaterialProps(material)}
side={THREE.DoubleSide}
@@ -81,8 +81,7 @@ export function MediaPlane({
<planeGeometry args={[w, h]} />
{texture ? (
<meshBasicMaterial
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
map={texture as any}
map={texture as THREE.Texture}
side={THREE.DoubleSide}
/>
) : (
@@ -50,9 +50,8 @@ function ModelInner({
const model = scene.clone();
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- @types/three version mismatch
model.traverse((node: any) => {
if (node.isMesh) {
model.traverse((node) => {
if ((node as THREE.Mesh).isMesh) {
node.castShadow = true;
node.receiveShadow = true;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-game-engine",
"version": "0.1.2",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-image
## 0.1.8
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/image@0.16.0
## 0.1.7
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/image@0.15.0
## 0.1.6
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-image",
"version": "0.1.8",
"version": "0.1.6",
"type": "module",
"private": true,
"scripts": {
-5
View File
@@ -1,5 +0,0 @@
# Required for local development — get from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# Optional — override the default model (anthropic/claude-haiku-4.5)
# AI_GATEWAY_MODEL=anthropic/claude-sonnet-4-20250514
-17
View File
@@ -1,17 +0,0 @@
# example-ink-chat
## 0.1.2
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/ink@0.16.0
## 0.1.1
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/ink@0.15.0
-27
View File
@@ -1,27 +0,0 @@
{
"name": "example-ink-chat",
"version": "0.1.2",
"type": "module",
"private": true,
"scripts": {
"dev": "tsx --env-file=.env src/index.tsx",
"build": "tsc",
"start": "node dist/index.js",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/ink": "workspace:*",
"ai": "^6.0.33",
"@ai-sdk/gateway": "^3.0.52",
"ink": "^6.8.0",
"react": "19.2.4",
"zod": "4.3.6"
},
"devDependencies": {
"@types/react": "19.2.3",
"@types/node": "^22.10.0",
"tsx": "^4.19.0",
"typescript": "^5.7.2"
}
}
-644
View File
@@ -1,644 +0,0 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { Box, Text, useInput, useApp, useStdout } from "ink";
import { streamText, stepCountIs } from "ai";
import { gateway } from "@ai-sdk/gateway";
import {
createMixedStreamParser,
createStateStore,
applySpecPatch,
type Spec,
} from "@json-render/core";
import { JSONUIProvider, Renderer, useFocusDisable } from "@json-render/ink";
import { catalog } from "./catalog.js";
import { tools } from "./tools.js";
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
// Component types stepped through one-at-a-time in the wizard.
// Tabs are excluded — they're navigation, rendered inline with the full spec.
const WIZARD_TYPES = new Set([
"TextInput",
"Select",
"MultiSelect",
"ConfirmInput",
]);
// Interactive component types that need live keyboard input (wizard types + Tabs)
const INTERACTIVE_TYPES = new Set([...WIZARD_TYPES, "Tabs"]);
/** Check if a spec contains any interactive components */
function hasInteractiveElements(spec: Spec): boolean {
return Object.values(spec.elements).some((el) =>
INTERACTIVE_TYPES.has(el.type),
);
}
/** Collect an element and all its descendants from the spec tree */
function collectSubtree(spec: Spec, rootKey: string): Spec["elements"] {
const result: Spec["elements"] = {};
const queue = [rootKey];
while (queue.length > 0) {
const key = queue.shift()!;
const el = spec.elements[key];
if (!el) continue;
result[key] = el;
if (el.children) queue.push(...el.children);
}
return result;
}
/** Get event→action bindings that auto-advance the wizard for each component type */
function getAdvanceEvents(
type: string,
): Record<string, Array<{ action: string }>> | null {
switch (type) {
case "Select":
return { change: [{ action: "advance" }] };
case "TextInput":
case "MultiSelect":
return { submit: [{ action: "advance" }] };
case "ConfirmInput":
return {
confirm: [{ action: "advance" }],
deny: [{ action: "advance" }],
};
default:
return null;
}
}
/** Step-specific hint text */
function getStepHint(type: string, isLast: boolean): string {
const action = isLast ? "submit" : "continue";
switch (type) {
case "Select":
return `Use arrow keys, Enter to ${action}`;
case "MultiSelect":
return `Space to toggle, Enter to ${action}`;
case "TextInput":
return `Type your answer, Enter to ${action}`;
case "ConfirmInput":
return `Press Y or N to ${action}`;
default:
return `Make your selection to ${action}`;
}
}
// ---------------------------------------------------------------------------
// System prompt — handwritten design guidance + catalog documentation.
// Follows the same pattern as examples/chat/lib/agent.ts: a rich
// AGENT_INSTRUCTIONS string with catalog.prompt() appended at the end.
// ---------------------------------------------------------------------------
const AGENT_INSTRUCTIONS = `You are a terminal assistant that renders polished, information-dense UIs. You call tools for real-time data, then build clean terminal dashboards.
WORKFLOW:
1. Call the appropriate tools to gather real data. Use web_search for topics not covered by the specialized tools (get_weather, get_hacker_news, get_github_repo, get_crypto_price).
2. While tools run, output a single short status line (e.g. "Looking up weather data..."). This is the ONLY text allowed outside the spec fence.
3. After tools return, output ALL content inside a \`\`\`spec fence. Never write paragraphs of prose outside the fence.
4. For simple text replies (greetings, clarifications), still use a \`\`\`spec with a Markdown component.
DESIGN PRINCIPLES:
- HIERARCHY: Every response needs clear visual structure. Start with an h1 Heading for the topic. Use h2 Headings for subsections. Use Card to group related content into shaded areas — Cards render as subtle background fills, not bordered boxes.
- LEAD WITH THE STORY: Open with a brief Markdown paragraph (2-3 sentences) that tells the user the key insight or takeaway. Don't just dump data — frame it.
- SUMMARY METRICS: After the narrative, show 2-4 Metric components for the most important numbers. Metric displays a dim label, bold value, and optional colored trend (up=green, down=red). Group them in a horizontal Box (flexDirection: row, gap: 3) so they read like a dashboard header. Use KeyValue only for simple label:value pairs that don't need emphasis.
- DETAIL SECTIONS: Below the summary, use h2 Headings to introduce each section, followed by a single focused visualization (Table, BarChart, or set of KeyValues).
- ONE REPRESENTATION PER DATA POINT: Never show the same value as both a number and a percentage and a bar. Pick the most meaningful format. Use BarChart with showValues:true OR showPercentage:true, not both.
- TABLES: Always set explicit column widths so columns don't collapse. Use headerColor:"cyan". Keep column headers short (abbreviate if needed). Right-align numeric columns.
- CHARTS: Use distinct colors per bar in BarChart. Good palette: cyan, green, yellow, magenta, blue, red. Use Sparkline for compact inline trends alongside other content.
- COLOR STRATEGY: Use color with intention, not decoration. cyan for labels and headers. green for positive values, growth, success. red for negative values, decline, errors. yellow for warnings or neutral highlights. dimColor:true for secondary/supporting text. Avoid coloring everything — contrast comes from restraint.
- TABLES: Use borderStyle:"single" on Tables for a clean outline. Do NOT put Tables inside Cards — Tables have their own border and don't need additional wrapping.
- SPACING: Use gap:1 between sections. Don't over-pad. Keep the UI compact and scannable. NEVER add padding to the root element — the app already provides outer padding.
- WIDTH: Target 80 columns. Set explicit widths on Tables (total columns should sum to ~70-75). Use wrap:"truncate-end" on Text in tight spaces.
- CALLOUTS: Use Callout for key takeaways, important notes, tips, and warnings. Set type (info/tip/warning/important) for a colored left border accent. Keep content concise — one key point per Callout.
- TIMELINES: Use Timeline for historical events, step-by-step processes, and milestones. Set status per item (completed/current/upcoming) for colored dots. Include dates when available.
- NEVER use emojis anywhere — not in text, labels, titles, table cells, Heading text, or component props. Plain text only.
DASHBOARD PATTERN (use for data-heavy responses):
Root Box (column, gap:1) >
Heading (h1, topic title)
Markdown (2-3 sentence summary with key takeaway)
Box (row, gap:3) > [Metric, Metric, Metric] (top-line metrics, no Card)
Heading (h2, section title)
Table (borderStyle:"single")
Card (title:"Section Name") > BarChart (bar charts go in a titled Card — the Card title replaces h2)
Callout (type:"tip", key takeaway or closing note)
Card wrapping rules: Wrap BarCharts in a Card with a title. Do NOT wrap Metrics or Tables in Cards — Metrics stand alone, Tables have their own border.
COMPARISON PATTERN:
Use BarChart when you want the user to see relative magnitudes at a glance.
Use Table when there are 3+ columns of mixed data types.
Never use both for the same data.
TREND PATTERN:
Use Sparkline for compact inline trend next to a KeyValue.
Use BarChart with year/period labels for detailed time-series.
INTERACTIVITY:
- You can create interactive forms, surveys, and selection interfaces. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.
- ALWAYS include a submit action on interactive UIs. Add a Text or StatusLine telling the user how to submit. Wire submit events to a "submit" action — the app collects form state automatically.
- ALWAYS populate the state field with sensible defaults for all bound values.
- Use $bindState on interactive components for two-way binding. Example: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.
- Use Tabs for multi-section surveys. Use ConfirmInput for yes/no prompts.
- After receiving form data, acknowledge the user's choices meaningfully — don't just echo them back.
${catalog.prompt({
mode: "inline",
customRules: [
"ALL text MUST go inside the spec using the Markdown component. The ONLY text outside the fence is a short tool-status line.",
"For text-only answers, still output a spec with a Markdown component.",
"Prefer Table for structured data and KeyValue for label-value pairs.",
"NEVER use emojis anywhere in your output. Plain text only.",
],
})}`;
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface Message {
id: number;
role: "user" | "assistant";
text: string;
spec: Spec | null;
}
// ---------------------------------------------------------------------------
// ChatInput — simple terminal text input
// ---------------------------------------------------------------------------
function ChatInput({
onSubmit,
disabled,
}: {
onSubmit: (text: string) => void;
disabled: boolean;
}) {
const [value, setValue] = useState("");
useInput(
(input, key) => {
if (key.return && value.trim()) {
onSubmit(value.trim());
setValue("");
return;
}
if (key.backspace || key.delete) {
setValue((prev) => prev.slice(0, -1));
return;
}
if (key.ctrl || key.meta || key.escape || key.tab) return;
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow)
return;
if (input) {
setValue((prev) => prev + input);
}
},
{ isActive: !disabled },
);
return (
<Box>
<Text bold>{"› "}</Text>
{value ? (
<Text>{value}</Text>
) : (
<Text dimColor>{disabled ? "Thinking..." : "Type a message..."}</Text>
)}
</Box>
);
}
// ---------------------------------------------------------------------------
// Small UI helpers
// ---------------------------------------------------------------------------
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
function AnimatedSpinner({
label,
color = "cyan",
}: {
label: string;
color?: string;
}) {
const [frame, setFrame] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
}, 80);
return () => clearInterval(timer);
}, []);
return (
<Box gap={1}>
<Text color={color}>{SPINNER_FRAMES[frame]}</Text>
<Text dimColor>{label}</Text>
</Box>
);
}
/** Suppress Tab-cycling inside read-only message providers so old interactive
* components (e.g. Tabs) can't steal focus/arrow-key input. */
function DisableFocus() {
useFocusDisable(true);
return null;
}
/** Render markdown text through the standard Renderer pipeline (uses the
* built-in Markdown component without exporting MarkdownText). */
function RenderedMarkdown({ text }: { text: string }) {
const spec: Spec = useMemo(
() => ({
root: "md",
elements: { md: { type: "Markdown", props: { text }, children: [] } },
}),
[text],
);
return (
<JSONUIProvider initialState={{}}>
<DisableFocus />
<Renderer spec={spec} />
</JSONUIProvider>
);
}
function MessageView({ message }: { message: Message }) {
if (message.role === "user") {
return (
<Box marginBottom={1}>
<Text bold>You: </Text>
<Text>{message.text}</Text>
</Box>
);
}
return (
<Box flexDirection="column" marginBottom={1}>
{message.spec ? (
<JSONUIProvider initialState={message.spec.state ?? {}}>
<DisableFocus />
<Renderer spec={message.spec} />
</JSONUIProvider>
) : message.text ? (
<RenderedMarkdown text={message.text} />
) : null}
</Box>
);
}
// ---------------------------------------------------------------------------
// LiveInteractiveSpec — wizard that shows one interactive element at a time
// ---------------------------------------------------------------------------
function LiveInteractiveSpec({
spec,
onSubmit,
}: {
spec: Spec;
onSubmit: (state: Record<string, unknown>) => void;
}) {
// Extract wizard-steppable element keys (Tabs are excluded)
const interactiveKeys = useMemo(
() =>
Object.entries(spec.elements)
.filter(([_, el]) => WIZARD_TYPES.has(el.type))
.map(([key]) => key),
[spec],
);
const [step, setStep] = useState(0);
const store = useMemo(() => createStateStore(spec.state ?? {}), [spec]);
// Guard against double-advance from key repeats (e.g. holding Y on ConfirmInput)
const advancingRef = useRef(false);
const currentKey = interactiveKeys[step];
const currentElement = currentKey ? spec.elements[currentKey] : null;
const isLast = step >= interactiveKeys.length - 1;
// Reset the guard when the step changes
useEffect(() => {
advancingRef.current = false;
}, [step]);
const advance = useCallback(() => {
if (advancingRef.current) return;
advancingRef.current = true;
if (isLast) {
onSubmit(store.getSnapshot());
} else {
setStep((s) => s + 1);
}
}, [isLast, onSubmit, store]);
// Build a minimal spec containing only the current interactive element
const stepSpec = useMemo<Spec | null>(() => {
if (!currentKey || !currentElement) return null;
const elements = collectSubtree(spec, currentKey);
// Wire auto-advance events
const advanceEvents = getAdvanceEvents(currentElement.type);
if (advanceEvents) {
elements[currentKey] = {
...elements[currentKey]!,
on: { ...(elements[currentKey] as any).on, ...advanceEvents },
};
}
return { root: currentKey, elements, state: spec.state };
}, [currentKey, currentElement, spec]);
const handlers = useMemo(() => ({ submit: advance, advance }), [advance]);
// No wizard-steppable elements (e.g. Tabs-only spec) → render the full spec
if (interactiveKeys.length === 0) {
return (
<Box flexDirection="column" marginBottom={1}>
<JSONUIProvider store={store} handlers={handlers}>
<Renderer spec={spec} />
</JSONUIProvider>
</Box>
);
}
if (!stepSpec || !currentElement) return null;
return (
<Box flexDirection="column" marginBottom={1}>
{interactiveKeys.length > 1 && (
<Text dimColor>
Step {step + 1} of {interactiveKeys.length}
</Text>
)}
<JSONUIProvider store={store} handlers={handlers}>
<Renderer spec={stepSpec} />
</JSONUIProvider>
<Box marginTop={1}>
<Text dimColor italic>
{getStepHint(currentElement.type, isLast)}
</Text>
</Box>
</Box>
);
}
// ---------------------------------------------------------------------------
// App — main chat loop
// ---------------------------------------------------------------------------
export function App() {
const { exit } = useApp();
const { stdout } = useStdout();
const [messages, setMessages] = useState<Message[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingStatus, setStreamingStatus] = useState("Thinking...");
const [streamingSpec, setStreamingSpec] = useState<Spec | null>(null);
const nextMessageIdRef = useRef(0);
const abortRef = useRef<AbortController | null>(null);
// Ref tracks latest messages so sendMessage doesn't need it as a dep
const messagesRef = useRef(messages);
messagesRef.current = messages;
// Track a live interactive spec awaiting user input
const [liveSpec, setLiveSpec] = useState<Spec | null>(null);
// Ctrl+C to exit (suppress Escape when interactive spec is live)
useInput((_input, key) => {
if (key.ctrl && _input === "c") {
abortRef.current?.abort();
exit();
}
if (key.escape && !liveSpec) {
abortRef.current?.abort();
exit();
}
});
const sendMessage = useCallback(async (text: string) => {
abortRef.current?.abort();
// Clear any live interactive spec
setLiveSpec(null);
// Add user message
const userMsg: Message = {
id: nextMessageIdRef.current++,
role: "user",
text,
spec: null,
};
setMessages((prev) => [...prev, userMsg]);
setIsStreaming(true);
setStreamingStatus("Thinking...");
// Build conversation history from ref (avoids stale closure).
// For assistant messages with specs, serialize the spec so the model
// remembers what it rendered in previous turns.
const history = [
...messagesRef.current.map((m) => ({
role: m.role as "user" | "assistant",
content: m.spec
? `${m.text}\n\`\`\`spec\n${JSON.stringify(m.spec)}\n\`\`\``
: m.text,
})),
{ role: "user" as const, content: text },
];
const controller = new AbortController();
abortRef.current = controller;
try {
const result = streamText({
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
system: AGENT_INSTRUCTIONS,
messages: history,
temperature: 0.7,
abortSignal: controller.signal,
tools,
stopWhen: stepCountIs(3),
});
let conversationText = "";
let spec: Spec = { root: "", elements: {} };
let hasSpec = false;
const parser = createMixedStreamParser({
onText: (chunk) => {
conversationText += chunk + "\n";
},
onPatch: (patch) => {
hasSpec = true;
spec = applySpecPatch(structuredClone(spec), patch);
setStreamingSpec(structuredClone(spec));
},
});
let hadTextInStep = false;
for await (const part of result.fullStream) {
if (part.type === "tool-call") {
const name = part.toolName.replace(/_/g, " ");
setStreamingStatus(`Using ${name}...`);
} else if (part.type === "tool-result") {
setStreamingStatus("Generating...");
} else if (part.type === "text-start") {
hadTextInStep = false;
} else if (part.type === "text-delta") {
hadTextInStep = true;
parser.push(part.text);
} else if (part.type === "text-end") {
// Insert a paragraph break between text segments so text from
// before/after tool calls doesn't merge into a wall.
// Injected directly into conversationText (not through the
// parser, which may drop empty lines in older builds).
if (hadTextInStep) {
parser.flush();
conversationText += "\n\n";
hadTextInStep = false;
}
}
}
parser.flush();
// Finalize: add assistant message
const finalSpec = hasSpec ? spec : null;
const isInteractive = finalSpec && hasInteractiveElements(finalSpec);
const assistantMsg: Message = {
id: nextMessageIdRef.current++,
role: "assistant",
text: conversationText.trim(),
// If interactive, don't store spec in message history (it'll be live)
spec: isInteractive ? null : finalSpec,
};
setMessages((prev) => [...prev, assistantMsg]);
// If the spec has interactive components, keep it live
if (isInteractive && finalSpec) {
setLiveSpec(finalSpec);
}
} catch (err) {
if ((err as Error).name === "AbortError") return;
const errorMsg: Message = {
id: nextMessageIdRef.current++,
role: "assistant",
text: `Error: ${(err as Error).message}`,
spec: null,
};
setMessages((prev) => [...prev, errorMsg]);
} finally {
setIsStreaming(false);
setStreamingSpec(null);
}
}, []);
// Handle interactive spec submission — collect state and send back to AI
const handleInteractiveSubmit = useCallback(
(state: Record<string, unknown>) => {
// Freeze the spec into message history as a non-interactive snapshot
if (liveSpec) {
// Update the last assistant message to include the spec with submitted state
const frozenSpec = { ...liveSpec, state };
setMessages((prev) => {
const updated = [...prev];
// Find the last assistant message (which has spec: null for interactive)
for (let i = updated.length - 1; i >= 0; i--) {
if (updated[i]!.role === "assistant" && !updated[i]!.spec) {
updated[i] = { ...updated[i]!, spec: frozenSpec };
break;
}
}
return updated;
});
setLiveSpec(null);
}
// Format the submitted state as a user message and send to AI
const formattedState = Object.entries(state)
.map(([key, value]) => {
if (Array.isArray(value)) return `${key}: ${value.join(", ")}`;
return `${key}: ${value}`;
})
.join("\n");
sendMessage(`[Form submitted]\n${formattedState}`);
},
[liveSpec, sendMessage],
);
return (
<Box flexDirection="column" padding={1} minHeight={stdout.rows}>
{/* Header */}
<Box marginBottom={1} gap={1}>
<Text bold color="cyan">
json-render
</Text>
<Text dimColor>Ctrl+C to exit</Text>
</Box>
{/* Empty state — show example prompts when no conversation yet */}
{messages.length === 0 && !isStreaming && (
<Box flexDirection="column" marginBottom={1}>
<Text dimColor>Try asking:</Text>
<Box flexDirection="column" paddingLeft={2} marginTop={1} gap={0}>
<Text dimColor>{" weather in tokyo"}</Text>
<Text dimColor>{" top hacker news stories"}</Text>
<Text dimColor>{" tell me about vercel/next.js"}</Text>
<Text dimColor>{" bitcoin price"}</Text>
</Box>
</Box>
)}
{/* Message history — collapsed when interactive wizard is active */}
{liveSpec && !isStreaming ? (
<>
{messages.length > 1 && (
<Text dimColor italic>
{messages.length - 1} earlier message
{messages.length > 2 ? "s" : ""} hidden
</Text>
)}
{messages.length > 0 && (
<MessageView message={messages[messages.length - 1]!} />
)}
<LiveInteractiveSpec
spec={liveSpec}
onSubmit={handleInteractiveSubmit}
/>
</>
) : (
<>
{messages.map((msg) => (
<MessageView key={msg.id} message={msg} />
))}
</>
)}
{/* Live spec preview while streaming */}
{isStreaming && streamingSpec && streamingSpec.root && (
<Box flexDirection="column" marginBottom={1}>
<JSONUIProvider initialState={streamingSpec.state ?? {}}>
<DisableFocus />
<Renderer spec={streamingSpec} loading />
</JSONUIProvider>
</Box>
)}
{/* Spacer pushes input to bottom when content is short */}
<Box flexGrow={1} />
{/* Input — spinner replaces input while streaming, hidden during wizard */}
{!liveSpec && (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
{isStreaming ? (
<AnimatedSpinner label={streamingStatus} />
) : (
<ChatInput onSubmit={sendMessage} disabled={false} />
)}
</Box>
)}
</Box>
);
}
-11
View File
@@ -1,11 +0,0 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/ink/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/ink/catalog";
export const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: standardActionDefinitions,
});
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env node
import { render } from "ink";
import { App } from "./app.js";
// Clear terminal and move cursor to top-left
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
render(<App />, { exitOnCtrlC: false });
-384
View File
@@ -1,384 +0,0 @@
/**
* Tools for the ink-chat example.
*
* All tools use free, public APIs — no API keys required beyond the
* AI Gateway key that is already configured.
*
* These match the tools in examples/chat for consistency across examples.
*/
import { tool, generateText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { z } from "zod";
// =============================================================================
// Web Search (Perplexity Sonar via AI Gateway)
// =============================================================================
export const webSearch = tool({
description:
"Search the web for current information on any topic. Returns a synthesized answer based on real-time web data.",
inputSchema: z.object({
query: z
.string()
.describe(
"The search query — be specific and include relevant context for better results",
),
}),
execute: async ({ query }) => {
try {
const { text } = await generateText({
model: gateway("perplexity/sonar"),
prompt: query,
});
return { content: text };
} catch (error) {
return {
error: `Search failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
// =============================================================================
// Weather (Open-Meteo — free, no API key)
// =============================================================================
function describeWeatherCode(code: number): string {
const descriptions: Record<number, string> = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
95: "Thunderstorm",
};
return descriptions[code] ?? "Unknown";
}
export const getWeather = tool({
description:
"Get current weather conditions and a 7-day forecast for a given city. Returns temperature, humidity, wind speed, weather conditions, and daily forecasts.",
inputSchema: z.object({
city: z
.string()
.describe("City name (e.g., 'New York', 'London', 'Tokyo')"),
}),
execute: async ({ city }) => {
try {
const geocodeUrl = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1&language=en&format=json`;
const geocodeRes = await fetch(geocodeUrl);
if (!geocodeRes.ok) {
return { error: `Failed to geocode city: ${city}` };
}
const geocodeData = (await geocodeRes.json()) as {
results?: Array<{
name: string;
country: string;
latitude: number;
longitude: number;
timezone: string;
}>;
};
if (!geocodeData.results || geocodeData.results.length === 0) {
return { error: `City not found: ${city}` };
}
const location = geocodeData.results[0]!;
const weatherUrl = `https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum&temperature_unit=fahrenheit&wind_speed_unit=mph&precipitation_unit=inch&timezone=${encodeURIComponent(location.timezone)}&forecast_days=7`;
const weatherRes = await fetch(weatherUrl);
if (!weatherRes.ok) {
return { error: "Failed to fetch weather data" };
}
const weather = (await weatherRes.json()) as {
current: {
temperature_2m: number;
relative_humidity_2m: number;
apparent_temperature: number;
weather_code: number;
wind_speed_10m: number;
};
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
precipitation_sum: number[];
};
};
const forecast = weather.daily.time.map((date, i) => ({
date,
day: new Date(date + "T12:00:00").toLocaleDateString("en-US", {
weekday: "short",
}),
high: Math.round(weather.daily.temperature_2m_max[i]!),
low: Math.round(weather.daily.temperature_2m_min[i]!),
condition: describeWeatherCode(weather.daily.weather_code[i]!),
precipitation: weather.daily.precipitation_sum[i]!,
}));
return {
city: location.name,
country: location.country,
current: {
temperature: Math.round(weather.current.temperature_2m),
feelsLike: Math.round(weather.current.apparent_temperature),
humidity: weather.current.relative_humidity_2m,
windSpeed: Math.round(weather.current.wind_speed_10m),
condition: describeWeatherCode(weather.current.weather_code),
},
forecast,
};
} catch (error) {
return {
error: `Weather fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
// =============================================================================
// Hacker News (Firebase API — free, no API key)
// =============================================================================
export const getHackerNewsTop = tool({
description:
"Get the current top stories from Hacker News, including title, score, author, URL, and comment count.",
inputSchema: z.object({
count: z
.number()
.min(1)
.max(30)
.describe("Number of top stories to fetch (1-30)"),
}),
execute: async ({ count }) => {
try {
const topRes = await fetch(
"https://hacker-news.firebaseio.com/v0/topstories.json?print=pretty",
{ signal: AbortSignal.timeout(5000) },
);
if (!topRes.ok) {
return { error: "Failed to fetch Hacker News top stories" };
}
const topIds = (await topRes.json()) as number[];
const storyIds = topIds.slice(0, count);
const stories = await Promise.all(
storyIds.map(async (id) => {
const storyRes = await fetch(
`https://hacker-news.firebaseio.com/v0/item/${id}.json?print=pretty`,
{ signal: AbortSignal.timeout(5000) },
);
if (!storyRes.ok) return null;
const story = (await storyRes.json()) as {
id: number;
title: string;
url?: string;
score: number;
by: string;
time: number;
descendants?: number;
};
return {
title: story.title,
url:
story.url ?? `https://news.ycombinator.com/item?id=${story.id}`,
score: story.score,
author: story.by,
comments: story.descendants ?? 0,
};
}),
);
return {
stories: stories.filter(Boolean),
fetchedAt: new Date().toISOString(),
};
} catch (error) {
return {
error: `Hacker News fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
// =============================================================================
// GitHub (Public API — free, no API key, 60 req/hr)
// =============================================================================
const ghHeaders = { Accept: "application/vnd.github.v3+json" };
export const getGitHubRepo = tool({
description:
"Get information about a public GitHub repository including stars, forks, open issues, description, and language breakdown.",
inputSchema: z.object({
owner: z.string().describe("Repository owner (e.g., 'vercel')"),
repo: z.string().describe("Repository name (e.g., 'next.js')"),
}),
execute: async ({ owner, repo }) => {
try {
const repoUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`;
const [repoRes, languagesRes] = await Promise.all([
fetch(repoUrl, { headers: ghHeaders }),
fetch(`${repoUrl}/languages`, { headers: ghHeaders }),
]);
if (!repoRes.ok) {
if (repoRes.status === 404)
return { error: `Not found: ${owner}/${repo}` };
return { error: `Failed to fetch repo: ${repoRes.statusText}` };
}
const repoData = (await repoRes.json()) as {
full_name: string;
description: string | null;
html_url: string;
stargazers_count: number;
forks_count: number;
open_issues_count: number;
language: string | null;
license: { spdx_id: string } | null;
topics: string[];
};
const languages: Record<string, number> = languagesRes.ok
? ((await languagesRes.json()) as Record<string, number>)
: {};
const totalBytes = Object.values(languages).reduce((a, b) => a + b, 0);
const languageBreakdown = Object.entries(languages)
.map(([lang, bytes]) => ({
language: lang,
percentage:
totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
}))
.sort((a, b) => b.percentage - a.percentage)
.slice(0, 6);
return {
name: repoData.full_name,
description: repoData.description,
url: repoData.html_url,
stars: repoData.stargazers_count,
forks: repoData.forks_count,
openIssues: repoData.open_issues_count,
primaryLanguage: repoData.language,
license: repoData.license?.spdx_id ?? "None",
topics: repoData.topics,
languages: languageBreakdown,
};
} catch (error) {
return {
error: `GitHub fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
// =============================================================================
// Crypto (CoinGecko — free, no API key)
// =============================================================================
export const getCryptoPrice = tool({
description:
"Get current price, market cap, 24h change, and 7-day trend for a cryptocurrency.",
inputSchema: z.object({
coinId: z
.string()
.describe(
"CoinGecko coin ID (e.g., 'bitcoin', 'ethereum', 'solana', 'dogecoin')",
),
}),
execute: async ({ coinId }) => {
try {
const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(coinId)}?localization=false&tickers=false&community_data=false&developer_data=false&sparkline=false`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
});
if (!res.ok) {
if (res.status === 404)
return { error: `Cryptocurrency not found: ${coinId}` };
if (res.status === 429)
return {
error: "CoinGecko rate limit exceeded. Try again in a minute.",
};
return { error: `Failed to fetch crypto data: ${res.statusText}` };
}
const data = (await res.json()) as {
id: string;
symbol: string;
name: string;
market_data: {
current_price: { usd: number };
market_cap: { usd: number };
total_volume: { usd: number };
price_change_percentage_24h: number;
price_change_percentage_7d: number;
high_24h: { usd: number };
low_24h: { usd: number };
};
market_cap_rank: number;
};
const md = data.market_data;
return {
symbol: data.symbol.toUpperCase(),
name: data.name,
rank: data.market_cap_rank,
price: md.current_price.usd,
marketCap: md.market_cap.usd,
volume24h: md.total_volume.usd,
change24h: Math.round(md.price_change_percentage_24h * 100) / 100,
change7d: Math.round(md.price_change_percentage_7d * 100) / 100,
high24h: md.high_24h.usd,
low24h: md.low_24h.usd,
};
} catch (error) {
return {
error: `Crypto fetch failed: ${error instanceof Error ? error.message : "Unknown error"}`,
};
}
},
});
// =============================================================================
// All tools (exported as a single record for streamText)
// =============================================================================
export const tools = {
web_search: webSearch,
get_weather: getWeather,
get_hacker_news: getHackerNewsTop,
get_github_repo: getGitHubRepo,
get_crypto_price: getCryptoPrice,
};
-16
View File
@@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}
-20
View File
@@ -1,25 +1,5 @@
# example-mcp
## 0.1.7
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/mcp@0.16.0
- @json-render/react@0.16.0
- @json-render/shadcn@0.16.0
## 0.1.6
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/mcp@0.15.0
- @json-render/react@0.15.0
- @json-render/shadcn@0.15.0
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-mcp",
"version": "0.1.7",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
@@ -1,11 +0,0 @@
# example-next-website-builder
## 0.1.1
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/next@0.16.0
- @json-render/react@0.16.0
- @json-render/shadcn@0.16.0
@@ -1,12 +0,0 @@
"use client";
import { NextAppProvider } from "@json-render/next";
import { registry } from "@/lib/registry";
export default function WebsiteLayout({
children,
}: {
children: React.ReactNode;
}) {
return <NextAppProvider registry={registry}>{children}</NextAppProvider>;
}
@@ -1,15 +0,0 @@
import { notFound } from "next/navigation";
import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
import { WebsiteRenderer } from "./renderer";
export { generateMetadata, generateStaticParams };
export default async function Page({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const data = await getPageData({ params });
if (!data) notFound();
return <WebsiteRenderer {...data} />;
}
@@ -1,7 +0,0 @@
"use client";
import { PageRenderer, type PageRendererProps } from "@json-render/next";
export function WebsiteRenderer(props: PageRendererProps) {
return <PageRenderer {...props} />;
}
@@ -1,11 +0,0 @@
import type { NextAppSpec } from "@json-render/next";
import { getSpec, setSpec } from "@/lib/spec-store";
export async function GET() {
return Response.json(getSpec());
}
export async function PUT(req: Request) {
setSpec((await req.json()) as NextAppSpec);
return Response.json({ ok: true });
}
@@ -1,5 +0,0 @@
import { Editor } from "@/components/editor";
export default function BuilderPage() {
return <Editor />;
}
@@ -1,101 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/shadcn/src/**/*.tsx";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@@ -1,23 +0,0 @@
import type { Metadata } from "next";
import { GeistSans } from "geist/font/sans";
import { GeistMono } from "geist/font/mono";
import "./globals.css";
export const metadata: Metadata = {
title: "Next Website Builder | json-render",
description:
"Build entire Next.js websites from JSON specs with @json-render/next",
icons: { icon: "/icon.svg" },
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
<body className="font-sans antialiased">{children}</body>
</html>
);
}
@@ -1,180 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent,
} from "react";
import { JsonEditor, type JsonValue } from "@visual-json/react";
import type { NextAppSpec } from "@json-render/next";
import { NextAppProvider, PageRenderer } from "@json-render/next";
import {
ResizablePanelGroup,
ResizablePanel,
ResizableHandle,
} from "@/components/ui/resizable";
import { AddressBar } from "@/components/route-tabs";
import { registry } from "@/lib/registry";
export function Editor() {
const [spec, setSpec] = useState<NextAppSpec | null>(null);
const [activeRoute, setActiveRoute] = useState("/");
const [sidebarOpen, setSidebarOpen] = useState(true);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
fetch("/api/spec")
.then((r) => r.json())
.then((data: NextAppSpec) => setSpec(data));
}, []);
const handleChange = useCallback((value: JsonValue) => {
const updated = value as unknown as NextAppSpec;
setSpec(updated);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
fetch("/api/spec", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updated),
});
}, 500);
}, []);
const handlePreviewClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || href.startsWith("http") || href.startsWith("mailto:")) return;
e.preventDefault();
setActiveRoute(href);
}, []);
const currentRoute = useMemo(() => {
if (!spec) return null;
return spec.routes[activeRoute] ?? null;
}, [spec, activeRoute]);
const layoutSpec = useMemo(() => {
if (!spec || !currentRoute?.layout || !spec.layouts) return null;
return spec.layouts[currentRoute.layout] ?? null;
}, [spec, currentRoute]);
const initialState = useMemo(() => {
if (!spec || !currentRoute) return undefined;
const merged: Record<string, unknown> = {};
if (spec.state) Object.assign(merged, spec.state);
if (currentRoute.page.state) Object.assign(merged, currentRoute.page.state);
return Object.keys(merged).length > 0 ? merged : undefined;
}, [spec, currentRoute]);
if (!spec) {
return (
<div className="flex items-center justify-center h-screen text-muted-foreground">
Loading...
</div>
);
}
return (
<div className="h-screen flex flex-col">
<div className="flex items-center justify-between px-4 h-12 border-b border-border bg-background shrink-0">
<span className="text-sm font-semibold">Next Website Builder</span>
<a
href="/"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View Website
</a>
</div>
<ResizablePanelGroup orientation="horizontal" className="flex-1">
<ResizablePanel defaultSize={45} minSize={25}>
<div className="h-full flex flex-col">
<div className="flex items-center justify-between px-3 h-10 border-b border-border bg-muted/30">
<span className="text-xs font-mono text-muted-foreground">
spec.json
</span>
<button
onClick={() => setSidebarOpen((v) => !v)}
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M9 3v18" />
</svg>
</button>
</div>
<div className="flex-1 overflow-auto">
<JsonEditor
value={spec as unknown as JsonValue}
onChange={handleChange}
sidebarOpen={sidebarOpen}
height="100%"
className="h-full"
style={
{
"--vj-bg": "var(--background)",
"--vj-bg-panel": "var(--background)",
"--vj-bg-hover": "var(--muted)",
"--vj-bg-selected": "var(--primary)",
"--vj-bg-selected-muted": "var(--muted)",
"--vj-text": "var(--foreground)",
"--vj-text-selected": "var(--primary-foreground)",
"--vj-text-muted": "var(--muted-foreground)",
"--vj-text-dim": "var(--muted-foreground)",
"--vj-border": "var(--border)",
"--vj-border-subtle": "var(--border)",
"--vj-accent": "var(--primary)",
"--vj-accent-muted": "var(--muted)",
"--vj-input-bg": "var(--secondary)",
"--vj-input-border": "var(--border)",
} as React.CSSProperties
}
/>
</div>
</div>
</ResizablePanel>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={55} minSize={30}>
<div className="h-full flex flex-col">
<AddressBar route={activeRoute} onNavigate={setActiveRoute} />
<div
className="flex-1 overflow-auto bg-background"
onClick={handlePreviewClick}
>
{currentRoute ? (
<NextAppProvider registry={registry}>
<PageRenderer
spec={currentRoute.page}
initialState={initialState}
layoutSpec={layoutSpec}
/>
</NextAppProvider>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
Route not found
</div>
)}
</div>
</div>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
@@ -1,71 +0,0 @@
"use client";
import { useCallback, useState, type KeyboardEvent } from "react";
interface AddressBarProps {
route: string;
onNavigate: (route: string) => void;
}
export function AddressBar({ route, onNavigate }: AddressBarProps) {
const [value, setValue] = useState(route);
const [focused, setFocused] = useState(false);
const commit = useCallback(() => {
let normalized = value.trim();
if (!normalized.startsWith("/")) normalized = "/" + normalized;
onNavigate(normalized);
setValue(normalized);
}, [value, onNavigate]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commit();
(e.target as HTMLInputElement).blur();
}
},
[commit],
);
const displayValue = focused ? value : route;
return (
<div className="flex items-center h-10 px-2 border-b border-border bg-muted/30">
<div className="flex items-center flex-1 h-7 rounded-md border border-border bg-background px-2 gap-1.5">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground shrink-0"
>
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
<input
type="text"
value={displayValue}
onChange={(e) => setValue(e.target.value)}
onFocus={() => {
setFocused(true);
setValue(route);
}}
onBlur={() => {
setFocused(false);
commit();
}}
onKeyDown={handleKeyDown}
spellCheck={false}
className="flex-1 bg-transparent text-xs font-mono text-foreground outline-none placeholder:text-muted-foreground"
placeholder="/"
/>
</div>
</div>
);
}
@@ -1,53 +0,0 @@
"use client";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
-7
View File
@@ -1,7 +0,0 @@
import { createNextApp } from "@json-render/next/server";
import { getSpec } from "./spec-store";
export const { getPageData, generateMetadata, generateStaticParams } =
createNextApp({
spec: () => getSpec(),
});
@@ -1,12 +0,0 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/next/server";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { websiteComponentDefinitions } from "./website-catalog";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
...websiteComponentDefinitions,
},
actions: {},
});
@@ -1,307 +0,0 @@
import type { NextAppSpec } from "@json-render/next";
export const defaultSpec: NextAppSpec = {
metadata: {
title: {
default: "Acme Inc",
template: "%s | Acme Inc",
},
description: "We build the future of software.",
icons: "/icon.svg",
},
state: {},
layouts: {
main: {
root: "wrapper",
elements: {
wrapper: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
className: "min-h-screen",
},
children: ["header", "content", "footer"],
},
header: {
type: "Header",
props: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
variant: "simple",
},
},
content: {
type: "Slot",
props: {},
},
footer: {
type: "Footer",
props: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
copyright: "2026 Acme Inc. All rights reserved.",
variant: "simple",
},
},
},
},
},
routes: {
"/": {
layout: "main",
metadata: {
title: "Home",
description: "Welcome to Acme Inc - we build the future of software.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["hero", "features", "testimonials", "cta"],
},
hero: {
type: "Hero",
props: {
headline: "Build the future with Acme",
description:
"We help companies ship better software, faster. Our platform provides the tools, infrastructure, and insights you need to build world-class products.",
primaryCta: { label: "Get Started", href: "/contact" },
secondaryCta: { label: "Learn More", href: "/about" },
badge: "Now in Beta",
variant: "centered",
},
},
features: {
type: "Features",
props: {
headline: "Why Acme?",
description:
"Everything you need to build, deploy, and scale your applications.",
items: [
{
title: "Lightning Fast",
description:
"Optimized for speed with edge-first architecture and smart caching.",
},
{
title: "Secure by Default",
description:
"Enterprise-grade security with end-to-end encryption and SOC 2 compliance.",
},
{
title: "Developer First",
description:
"APIs, SDKs, and CLI tools designed to fit your workflow perfectly.",
},
],
columns: 3,
variant: "cards",
},
},
testimonials: {
type: "Testimonials",
props: {
headline: "Trusted by industry leaders",
items: [
{
quote:
"Acme has completely transformed how we ship products. Our deployment time went from hours to minutes.",
author: "Jordan Lee",
role: "VP Engineering at Globex",
},
{
quote:
"The developer experience is unmatched. Our team onboarded in a single afternoon.",
author: "Priya Patel",
role: "CTO at Initech",
},
{
quote:
"We cut our infrastructure costs by 40% within the first quarter of switching to Acme.",
author: "Sam Ortiz",
role: "Head of Platform at Hooli",
},
],
},
},
cta: {
type: "CTA",
props: {
headline: "Ready to get started?",
description:
"Join thousands of teams building better software with Acme.",
buttonLabel: "Contact Us",
buttonHref: "/contact",
variant: "banner",
},
},
},
},
},
"/about": {
layout: "main",
metadata: {
title: "About",
description:
"Learn about our mission, values, and the team behind Acme.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["hero", "features", "team", "cta"],
},
hero: {
type: "Hero",
props: {
headline: "About Acme Inc",
description:
"Founded in 2024, we are on a mission to make software development accessible to everyone. We believe the best tools should be simple, powerful, and delightful to use.",
variant: "centered",
primaryCta: null,
secondaryCta: null,
badge: null,
},
},
features: {
type: "Features",
props: {
headline: "Our Values",
items: [
{
title: "Simplicity",
description:
"We remove complexity so you can focus on building great products.",
},
{
title: "Transparency",
description:
"Open communication, honest pricing, and clear documentation.",
},
{
title: "Craftsmanship",
description:
"Every detail matters. We sweat the small stuff so you don't have to.",
},
],
columns: 3,
variant: "simple",
description: null,
},
},
team: {
type: "Team",
props: {
headline: "Meet the Team",
description:
"The people behind Acme who are passionate about building great developer tools.",
members: [
{
name: "Alex Chen",
role: "CEO & Co-founder",
bio: "Previously at Vercel and Google. Passionate about developer experience.",
},
{
name: "Sarah Kim",
role: "CTO & Co-founder",
bio: "Built distributed systems at AWS. Loves solving hard infrastructure problems.",
},
{
name: "Marcus Rivera",
role: "Head of Design",
bio: "Former design lead at Figma and Stripe. Obsessed with clarity and craft.",
},
],
variant: "grid",
},
},
cta: {
type: "CTA",
props: {
headline: "Want to join us?",
description:
"We are always looking for talented people to join our team.",
buttonLabel: "Get in Touch",
buttonHref: "/contact",
variant: "centered",
},
},
},
},
},
"/contact": {
layout: "main",
metadata: {
title: "Contact",
description: "Get in touch with the Acme team.",
},
page: {
root: "page",
elements: {
page: {
type: "Stack",
props: {
direction: "vertical",
gap: "none",
align: "stretch",
},
children: ["form"],
},
form: {
type: "ContactForm",
props: {
headline: "Get in Touch",
description:
"Have a question or want to work with us? Fill out the form below and we'll get back to you within 24 hours.",
fields: [
{
label: "Name",
type: "text" as const,
placeholder: "Your name",
},
{
label: "Email",
type: "email" as const,
placeholder: "you@example.com",
},
{
label: "Message",
type: "textarea" as const,
placeholder: "Tell us about your project...",
},
],
submitLabel: "Send Message",
},
},
},
},
},
},
};
@@ -1,14 +0,0 @@
"use client";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
import { websiteComponents } from "./website-components";
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
...websiteComponents,
},
actions: {},
});
@@ -1,12 +0,0 @@
import type { NextAppSpec } from "@json-render/next";
import { defaultSpec } from "./default-spec";
let currentSpec: NextAppSpec = defaultSpec;
export function getSpec(): NextAppSpec {
return currentSpec;
}
export function setSpec(spec: NextAppSpec): void {
currentSpec = spec;
}
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -1,188 +0,0 @@
import { z } from "zod";
const linkSchema = z.object({
label: z.string(),
href: z.string(),
});
const ctaSchema = z.object({
label: z.string(),
href: z.string(),
});
export const websiteComponentDefinitions = {
Header: {
props: z.object({
brand: z.string(),
links: z.array(linkSchema),
variant: z.enum(["simple", "centered"]).nullable(),
}),
description:
"Site header / navigation bar. Simple: brand left, links right. Centered: brand centered above links.",
example: {
brand: "Acme Inc",
links: [
{ label: "Home", href: "/" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
],
variant: "simple",
},
},
Hero: {
props: z.object({
headline: z.string(),
description: z.string(),
primaryCta: ctaSchema.nullable(),
secondaryCta: ctaSchema.nullable(),
badge: z.string().nullable(),
variant: z.enum(["centered", "left-aligned"]).nullable(),
}),
description:
"Landing hero section with headline, description, and call-to-action buttons.",
example: {
headline: "Build something great",
description: "A modern platform for modern teams.",
primaryCta: { label: "Get Started", href: "/contact" },
variant: "centered",
},
},
Features: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
items: z.array(
z.object({
title: z.string(),
description: z.string(),
}),
),
columns: z.number().nullable(),
variant: z.enum(["cards", "simple"]).nullable(),
}),
description:
"Feature highlights section. Cards: bordered cards per item. Simple: clean text layout.",
example: {
headline: "Why choose us",
items: [
{ title: "Fast", description: "Blazing performance." },
{ title: "Secure", description: "Enterprise-grade security." },
{ title: "Simple", description: "Easy to use." },
],
columns: 3,
variant: "cards",
},
},
Team: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
members: z.array(
z.object({
name: z.string(),
role: z.string(),
bio: z.string().nullable(),
}),
),
variant: z.enum(["grid", "list"]).nullable(),
}),
description: "Team members section. Grid: card grid. List: vertical list.",
example: {
headline: "Our Team",
members: [
{ name: "Alice", role: "CEO", bio: null },
{ name: "Bob", role: "CTO", bio: null },
],
variant: "grid",
},
},
Testimonials: {
props: z.object({
headline: z.string().nullable(),
items: z.array(
z.object({
quote: z.string(),
author: z.string(),
role: z.string().nullable(),
}),
),
}),
description: "Customer testimonials / quotes section.",
example: {
headline: "What people say",
items: [
{
quote: "Amazing product!",
author: "Jane",
role: "CEO at Startup",
},
],
},
},
ContactForm: {
props: z.object({
headline: z.string().nullable(),
description: z.string().nullable(),
fields: z.array(
z.object({
label: z.string(),
type: z.enum(["text", "email", "textarea"]),
placeholder: z.string().nullable(),
}),
),
submitLabel: z.string(),
}),
description: "Contact form section with configurable fields.",
example: {
headline: "Get in Touch",
fields: [
{ label: "Name", type: "text", placeholder: "Your name" },
{ label: "Email", type: "email", placeholder: "you@example.com" },
{ label: "Message", type: "textarea", placeholder: "Your message" },
],
submitLabel: "Send",
},
},
Footer: {
props: z.object({
brand: z.string().nullable(),
links: z.array(linkSchema).nullable(),
copyright: z.string().nullable(),
variant: z.enum(["simple", "columns"]).nullable(),
}),
description:
"Site footer. Simple: centered single row. Columns: multi-column layout.",
example: {
brand: "Acme Inc",
copyright: "2026 Acme Inc.",
variant: "simple",
},
},
CTA: {
props: z.object({
headline: z.string(),
description: z.string().nullable(),
buttonLabel: z.string(),
buttonHref: z.string(),
variant: z.enum(["banner", "centered"]).nullable(),
}),
description:
"Call-to-action section. Banner: full-width colored background. Centered: clean centered layout.",
example: {
headline: "Ready to get started?",
buttonLabel: "Contact Us",
buttonHref: "/contact",
variant: "centered",
},
},
};
export type WebsiteProps<K extends keyof typeof websiteComponentDefinitions> =
z.output<(typeof websiteComponentDefinitions)[K]["props"]>;
@@ -1,501 +0,0 @@
"use client";
import type { BaseComponentProps } from "@json-render/react";
import type { WebsiteProps } from "./website-catalog";
// =============================================================================
// Header
// =============================================================================
function HeaderComponent({
props,
}: BaseComponentProps<WebsiteProps<"Header">>) {
const variant = props.variant ?? "simple";
const links = props.links ?? [];
if (variant === "centered") {
return (
<header className="border-b border-border bg-background">
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col items-center gap-4">
<span className="text-xl font-bold tracking-tight">
{props.brand}
</span>
<nav className="flex items-center gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
</div>
</header>
);
}
return (
<header className="border-b border-border bg-background sticky top-0 z-50">
<div className="mx-auto max-w-6xl px-6 h-16 flex items-center justify-between">
<span className="text-lg font-bold tracking-tight">{props.brand}</span>
<nav className="flex items-center gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
</div>
</header>
);
}
// =============================================================================
// Hero
// =============================================================================
function HeroComponent({ props }: BaseComponentProps<WebsiteProps<"Hero">>) {
const variant = props.variant ?? "centered";
const isLeft = variant === "left-aligned";
return (
<section className="bg-background">
<div
className={`mx-auto max-w-4xl px-6 py-24 md:py-32 ${isLeft ? "text-left" : "text-center"}`}
>
<div
className={`flex flex-col gap-6 ${isLeft ? "items-start" : "items-center"}`}
>
{props.badge && (
<span className="inline-flex items-center rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
{props.badge}
</span>
)}
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
{props.headline}
</h1>
<p
className={`text-lg text-muted-foreground ${isLeft ? "max-w-xl" : "max-w-2xl"}`}
>
{props.description}
</p>
{(props.primaryCta || props.secondaryCta) && (
<div className="flex flex-wrap gap-3 pt-2">
{props.primaryCta && (
<a
href={props.primaryCta.href}
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.primaryCta.label}
</a>
)}
{props.secondaryCta && (
<a
href={props.secondaryCta.href}
className="inline-flex h-11 items-center justify-center rounded-md border border-border bg-background px-6 text-sm font-medium text-foreground hover:bg-muted transition-colors"
>
{props.secondaryCta.label}
</a>
)}
</div>
)}
</div>
</div>
</section>
);
}
// =============================================================================
// Features
// =============================================================================
function FeaturesComponent({
props,
}: BaseComponentProps<WebsiteProps<"Features">>) {
const variant = props.variant ?? "cards";
const columns = props.columns ?? 3;
const items = props.items ?? [];
const colClass =
columns === 2
? "sm:grid-cols-2"
: columns === 4
? "sm:grid-cols-2 lg:grid-cols-4"
: "sm:grid-cols-2 lg:grid-cols-3";
return (
<section className="bg-background">
<div className="mx-auto max-w-6xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-12 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
{props.description}
</p>
)}
</div>
)}
<div className={`grid grid-cols-1 gap-6 ${colClass}`}>
{items.map((item, i) =>
variant === "cards" ? (
<div
key={i}
className="rounded-lg border border-border bg-card p-6 shadow-sm"
>
<h3 className="text-lg font-semibold text-card-foreground">
{item.title}
</h3>
<p className="mt-2 text-sm text-muted-foreground">
{item.description}
</p>
</div>
) : (
<div key={i} className="py-2">
<h3 className="text-lg font-semibold text-foreground">
{item.title}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{item.description}
</p>
</div>
),
)}
</div>
</div>
</section>
);
}
// =============================================================================
// Team
// =============================================================================
function TeamComponent({ props }: BaseComponentProps<WebsiteProps<"Team">>) {
const variant = props.variant ?? "grid";
const members = props.members ?? [];
return (
<section className="bg-background">
<div className="mx-auto max-w-6xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-12 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
{props.description}
</p>
)}
</div>
)}
{variant === "list" ? (
<div className="space-y-6 max-w-2xl mx-auto">
{members.map((m, i) => (
<div key={i} className="flex flex-col gap-1">
<h3 className="text-lg font-semibold text-foreground">
{m.name}
</h3>
<p className="text-sm text-muted-foreground">{m.role}</p>
{m.bio && (
<p className="mt-1 text-sm text-muted-foreground">{m.bio}</p>
)}
</div>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{members.map((m, i) => (
<div
key={i}
className="rounded-lg border border-border bg-card p-6 text-center"
>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-bold text-muted-foreground">
{m.name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase()}
</div>
<h3 className="text-base font-semibold text-card-foreground">
{m.name}
</h3>
<p className="text-sm text-muted-foreground">{m.role}</p>
{m.bio && (
<p className="mt-2 text-sm text-muted-foreground">{m.bio}</p>
)}
</div>
))}
</div>
)}
</div>
</section>
);
}
// =============================================================================
// Testimonials
// =============================================================================
function TestimonialsComponent({
props,
}: BaseComponentProps<WebsiteProps<"Testimonials">>) {
const items = props.items ?? [];
return (
<section className="bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-20">
{props.headline && (
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, i) => (
<blockquote
key={i}
className="rounded-lg border border-border bg-background p-6"
>
<p className="text-sm text-foreground leading-relaxed">
&ldquo;{item.quote}&rdquo;
</p>
<footer className="mt-4 flex flex-col">
<span className="text-sm font-semibold text-foreground">
{item.author}
</span>
{item.role && (
<span className="text-xs text-muted-foreground">
{item.role}
</span>
)}
</footer>
</blockquote>
))}
</div>
</div>
</section>
);
}
// =============================================================================
// ContactForm
// =============================================================================
function ContactFormComponent({
props,
}: BaseComponentProps<WebsiteProps<"ContactForm">>) {
const fields = props.fields ?? [];
return (
<section className="bg-background">
<div className="mx-auto max-w-xl px-6 py-20">
{(props.headline || props.description) && (
<div className="mb-8 text-center">
{props.headline && (
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
)}
{props.description && (
<p className="mt-3 text-muted-foreground">{props.description}</p>
)}
</div>
)}
<form
className="rounded-lg border border-border bg-card p-6 shadow-sm"
onSubmit={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-4">
{fields.map((field, i) =>
field.type === "textarea" ? (
<div key={i} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{field.label}
</label>
<textarea
placeholder={field.placeholder ?? ""}
rows={4}
className="rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
) : (
<div key={i} className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
{field.label}
</label>
<input
type={field.type}
placeholder={field.placeholder ?? ""}
className="h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
),
)}
<button
type="submit"
className="mt-2 h-11 rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.submitLabel}
</button>
</div>
</form>
</div>
</section>
);
}
// =============================================================================
// Footer
// =============================================================================
function FooterComponent({
props,
}: BaseComponentProps<WebsiteProps<"Footer">>) {
const variant = props.variant ?? "simple";
const links = props.links ?? [];
if (variant === "columns") {
return (
<footer className="border-t border-border bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="flex flex-col gap-8 sm:flex-row sm:justify-between">
<div>
{props.brand && (
<span className="text-lg font-bold text-foreground">
{props.brand}
</span>
)}
</div>
{links.length > 0 && (
<nav className="flex flex-col gap-2 sm:flex-row sm:gap-6">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
)}
</div>
{props.copyright && (
<p className="mt-8 text-xs text-muted-foreground">
{props.copyright}
</p>
)}
</div>
</footer>
);
}
return (
<footer className="border-t border-border bg-muted/30">
<div className="mx-auto max-w-6xl px-6 py-10 flex flex-col items-center gap-4">
{props.brand && (
<span className="text-sm font-semibold text-foreground">
{props.brand}
</span>
)}
{links.length > 0 && (
<nav className="flex items-center gap-4">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{link.label}
</a>
))}
</nav>
)}
{props.copyright && (
<p className="text-xs text-muted-foreground">{props.copyright}</p>
)}
</div>
</footer>
);
}
// =============================================================================
// CTA
// =============================================================================
function CTAComponent({ props }: BaseComponentProps<WebsiteProps<"CTA">>) {
const variant = props.variant ?? "centered";
if (variant === "banner") {
return (
<section className="bg-primary text-primary-foreground">
<div className="mx-auto max-w-6xl px-6 py-16 flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
<div>
<h2 className="text-2xl font-bold">{props.headline}</h2>
{props.description && (
<p className="mt-1 text-sm opacity-90">{props.description}</p>
)}
</div>
<a
href={props.buttonHref}
className="inline-flex h-11 items-center justify-center rounded-md bg-background px-6 text-sm font-medium text-foreground shadow-sm hover:bg-background/90 transition-colors shrink-0"
>
{props.buttonLabel}
</a>
</div>
</section>
);
}
return (
<section className="bg-muted/30">
<div className="mx-auto max-w-3xl px-6 py-20 text-center">
<h2 className="text-3xl font-bold tracking-tight text-foreground">
{props.headline}
</h2>
{props.description && (
<p className="mt-3 text-lg text-muted-foreground">
{props.description}
</p>
)}
<div className="mt-8">
<a
href={props.buttonHref}
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-8 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
>
{props.buttonLabel}
</a>
</div>
</div>
</section>
);
}
// =============================================================================
// Export map
// =============================================================================
export const websiteComponents = {
Header: HeaderComponent,
Hero: HeroComponent,
Features: FeaturesComponent,
Team: TeamComponent,
Testimonials: TestimonialsComponent,
ContactForm: ContactFormComponent,
Footer: FooterComponent,
CTA: CTAComponent,
};
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,12 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["next-website-builder-demo.json-render.localhost"],
transpilePackages: [
"@json-render/core",
"@json-render/react",
"@json-render/shadcn",
],
};
export default nextConfig;
@@ -1,41 +0,0 @@
{
"name": "example-next-website-builder",
"version": "0.1.1",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless next-website-builder-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/next": "workspace:*",
"@json-render/shadcn": "workspace:*",
"@visual-json/react": "0.3.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.7.0",
"lucide-react": "^1.6.0",
"next": "16.2.1",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-resizable-panels": "^4.7.6",
"tailwind-merge": "^3.5.0",
"zod": "4.3.6"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"tailwindcss": "^4.2.2",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
@@ -1,13 +0,0 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
-18
View File
@@ -1,23 +1,5 @@
# example-no-ai
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
- @json-render/shadcn@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
- @json-render/shadcn@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-react-email
## 0.1.7
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react-email@0.16.0
## 0.1.6
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react-email@0.15.0
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-email",
"version": "0.1.7",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-react-native
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react-native@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react-native@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.11",
"version": "0.1.9",
"private": true,
"main": "expo-router/entry",
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-react-pdf
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react-pdf@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react-pdf@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-18
View File
@@ -1,23 +1,5 @@
# example-react-three-fiber
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
- @json-render/react-three-fiber@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
- @json-render/react-three-fiber@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-three-fiber",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-remotion
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/remotion@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/remotion@0.15.0
## 0.1.9
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.11",
"version": "0.1.9",
"type": "module",
"private": true,
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-solid
## 0.1.5
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/solid@0.16.0
## 0.1.4
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/solid@0.15.0
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-solid",
"version": "0.1.5",
"version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -1,21 +1,5 @@
# com.example.json-render-demo
## 0.0.12
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
## 0.0.11
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
## 0.0.10
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.12",
"version": "0.0.10",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -1,21 +1,5 @@
# com.example.json-render-fullpage-demo
## 0.0.12
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
## 0.0.11
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
## 0.0.10
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.12",
"version": "0.0.10",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
-16
View File
@@ -1,21 +1,5 @@
# svelte-chat
## 0.0.8
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/svelte@0.16.0
## 0.0.7
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/svelte@0.15.0
## 0.0.6
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "svelte-chat",
"private": true,
"version": "0.0.8",
"version": "0.0.6",
"type": "module",
"scripts": {
"dev": "vite dev",
-16
View File
@@ -1,21 +1,5 @@
# example-svelte
## 0.1.8
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/svelte@0.16.0
## 0.1.7
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/svelte@0.15.0
## 0.1.6
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-svelte",
"version": "0.1.8",
"version": "0.1.6",
"private": true,
"type": "module",
"scripts": {
-22
View File
@@ -1,27 +1,5 @@
# vite-renderers
## 0.1.9
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
- @json-render/solid@0.16.0
- @json-render/svelte@0.16.0
- @json-render/vue@0.16.0
## 0.1.8
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/react@0.15.0
- @json-render/solid@0.15.0
- @json-render/svelte@0.15.0
- @json-render/vue@0.15.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vite-renderers",
"version": "0.1.9",
"version": "0.1.7",
"private": true,
"type": "module",
"scripts": {
-16
View File
@@ -1,21 +1,5 @@
# example-vue
## 0.1.9
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/vue@0.16.0
## 0.1.8
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/vue@0.15.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-vue",
"version": "0.1.9",
"version": "0.1.7",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
-14
View File
@@ -1,19 +1,5 @@
# @json-render/codegen
## 0.16.0
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
## 0.15.0
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
## 0.14.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.16.0",
"version": "0.14.1",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
-22
View File
@@ -1,27 +1,5 @@
# @json-render/core
## 0.16.0
### Minor Changes
- 519a538: Add Next.js renderer and shadcn-svelte component library.
### New:
- **`@json-render/next`** -- Next.js renderer that turns JSON specs into full Next.js applications with routes, layouts, SSR, metadata, data loaders, and static generation. Client and server entry points at `@json-render/next` and `@json-render/next/server`. Includes built-in `Link`, `Slot`, error boundary, loading, and not-found components.
- **`@json-render/shadcn-svelte`** -- Pre-built shadcn-svelte components for json-render Svelte apps. 36 components built on Svelte 5 + Tailwind CSS with state binding, validation, and action support. Server-safe catalog at `@json-render/shadcn-svelte/catalog`.
## 0.15.0
### Minor Changes
- bf3a7ec: Add Ink terminal renderer for interactive terminal UIs.
### New:
- **`@json-render/ink`** -- Terminal UI renderer for json-render, built on Ink. Includes 20+ standard components (Box, Text, Heading, Card, Table, TextInput, Select, MultiSelect, Tabs, etc.), action/validation/focus contexts, two-way state binding, and streaming via `useUIStream`. Server-safe entry points at `@json-render/ink/schema`, `@json-render/ink/catalog`, and `@json-render/ink/server`.
### Improved:
- **Examples** -- new `ink-chat` terminal chat demo, `game-engine` 3D example using react-three-fiber, website examples page with live demos and search.
## 0.14.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.16.0",
"version": "0.14.1",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
-14
View File
@@ -1,19 +1,5 @@
# @json-render/image
## 0.16.0
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
## 0.15.0
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
## 0.14.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.16.0",
"version": "0.14.1",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
-25
View File
@@ -1,25 +0,0 @@
# @json-render/ink
## 0.16.0
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
## 0.15.0
### Minor Changes
- bf3a7ec: Add Ink terminal renderer for interactive terminal UIs.
### New:
- **`@json-render/ink`** -- Terminal UI renderer for json-render, built on Ink. Includes 20+ standard components (Box, Text, Heading, Card, Table, TextInput, Select, MultiSelect, Tabs, etc.), action/validation/focus contexts, two-way state binding, and streaming via `useUIStream`. Server-safe entry points at `@json-render/ink/schema`, `@json-render/ink/catalog`, and `@json-render/ink/server`.
### Improved:
- **Examples** -- new `ink-chat` terminal chat demo, `game-engine` 3D example using react-three-fiber, website examples page with live demos and search.
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
-139
View File
@@ -1,139 +0,0 @@
# @json-render/ink
Ink terminal renderer for json-render. Turn JSON specs into interactive terminal UIs.
## Installation
```bash
npm install @json-render/ink @json-render/core ink react
```
Peer dependencies: `ink ^6.0.0` and `react ^19.0.0`.
## Quick Start
### 1. Create a Catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/ink/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/ink/catalog";
export const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: standardActionDefinitions,
});
```
### 2. Render a Spec
```tsx
import { render } from "ink";
import { createRenderer, standardComponents } from "@json-render/ink";
import { catalog } from "./catalog";
const InkRenderer = createRenderer(catalog, standardComponents);
const spec = {
root: "heading",
elements: {
heading: {
type: "Heading",
props: { text: "Hello from the terminal!", level: "h1" },
children: [],
},
},
};
render(<InkRenderer spec={spec} state={{}} />);
```
## Standard Components
### Layout
| Component | Description |
|-----------|-------------|
| `Box` | Flexbox layout container (like a terminal `<div>`) |
| `Text` | Text output with optional styling (color, bold, italic, etc.) |
| `Newline` | Inserts one or more blank lines |
| `Spacer` | Flexible empty space that expands to fill available room |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Section heading (h1–h4) |
| `Divider` | Horizontal separator line with optional title |
| `Badge` | Small colored inline label for status |
| `Spinner` | Animated loading spinner with optional label |
| `ProgressBar` | Horizontal progress bar (0–1) |
| `Sparkline` | Inline sparkline chart using Unicode blocks |
| `BarChart` | Horizontal bar chart with labels and values |
| `Table` | Tabular data display with headers and rows |
| `List` | Bulleted or numbered list |
| `ListItem` | Structured list row with title, subtitle, leading/trailing |
| `Card` | Bordered container with optional title |
| `KeyValue` | Key-value pair display |
| `Link` | Clickable URL |
| `StatusLine` | Status message with colored icon |
| `Markdown` | Renders markdown-formatted text with terminal styling |
### Interactive
| Component | Description |
|-----------|-------------|
| `TextInput` | Text input field with two-way binding |
| `Select` | Selection menu navigated with arrow keys |
| `MultiSelect` | Multi-selection with space to toggle, enter to confirm |
| `ConfirmInput` | Yes/No confirmation prompt |
| `Tabs` | Tab bar navigation with left/right arrow keys |
## Generate AI Prompts
```typescript
const systemPrompt = catalog.prompt({ system: "You are a terminal assistant." });
```
## Streaming
Use `useUIStream` to progressively render specs from JSONL patch streams:
```tsx
import { useUIStream } from "@json-render/ink";
const { spec, send, isStreaming } = useUIStream({ api: "/api/generate" });
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `createRenderer` | Create an all-in-one renderer component from a catalog |
| `Renderer` | Low-level spec renderer |
| `JSONUIProvider` | Combined provider (state, visibility, validation, actions, focus) |
| `standardComponents` | Pre-built component implementations for all standard components |
| `schema` | Ink element tree schema |
| `useStateStore` | Access state context (`state`, `get`, `set`, `update`) |
| `useStateValue` | Get single value from state |
| `useBoundProp` | Two-way binding for `$bindState`/`$bindItem` expressions |
| `useUIStream` | Stream specs from an API endpoint |
| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
### Catalog Entry Points
| Entry Point | Exports |
|-------------|---------|
| `@json-render/ink` | Components, renderer, hooks, providers |
| `@json-render/ink/schema` | `schema`, `InkSchema`, `InkSpec` |
| `@json-render/ink/catalog` | `standardComponentDefinitions`, `standardActionDefinitions` |
| `@json-render/ink/server` | Server-safe re-exports (schema + catalog, no React dependency) |
## Documentation
- [API Reference](https://json-render.dev/docs/api/ink)
- [Renderers Overview](https://json-render.dev/docs/renderers)
- [Ink Chat Example](https://github.com/vercel-labs/json-render/tree/main/examples/ink-chat)
-79
View File
@@ -1,79 +0,0 @@
{
"name": "@json-render/ink",
"version": "0.16.0",
"license": "Apache-2.0",
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
"keywords": [
"json",
"cli",
"terminal",
"ink",
"ai",
"generative-ui",
"llm",
"renderer",
"tui"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/ink"
},
"homepage": "https://json-render.dev",
"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"
},
"./schema": {
"types": "./dist/schema.d.ts",
"import": "./dist/schema.mjs",
"require": "./dist/schema.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"marked": "^17.0.0"
},
"devDependencies": {
"@internal/react-state": "workspace:*",
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"ink": "^6.0.0",
"react": "^19.0.0"
}
}
-102
View File
@@ -1,102 +0,0 @@
import type { ReactNode } from "react";
import type {
Catalog,
InferCatalogComponents,
InferCatalogActions,
InferComponentProps,
InferActionParams,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
/**
* State setter function for updating application state
*/
export type SetState = (path: string, value: unknown) => void;
// =============================================================================
// Component Types
// =============================================================================
/**
* Context passed to component render functions
* @example
* const StatusBadge: ComponentFn<typeof catalog, 'Badge'> = (ctx) => {
* return <Text color="green">{ctx.props.label}</Text>
* }
*/
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: ReactNode;
/** Emit a named event. The renderer resolves the event to an action binding from the element's `on` field. */
emit: (event: string) => void;
/**
* Two-way binding paths resolved from `$bindState` / `$bindItem` expressions.
* Maps prop name → absolute state path for write-back.
*/
bindings?: Record<string, string>;
loading?: boolean;
}
/**
* Component render function type for Ink
* @example
* const Badge: ComponentFn<typeof catalog, 'Badge'> = ({ props }) => (
* <Text color="green">{props.label}</Text>
* );
*/
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => ReactNode;
/**
* Registry of all component render functions for a catalog
* @example
* const components: Components<typeof myCatalog> = {
* Badge: ({ props }) => <Text color="green">{props.label}</Text>,
* Card: ({ props, children }) => <Box borderStyle="round">{children}</Box>,
* };
*/
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
// =============================================================================
// Action Types
// =============================================================================
/**
* Action handler function type
* @example
* const exit: ActionFn<typeof catalog, 'exit'> = async (params, setState) => {
* process.exit(0);
* };
*/
export type ActionFn<
C extends Catalog,
K extends keyof InferCatalogActions<C>,
> = (
params: InferActionParams<C, K> | undefined,
setState: SetState,
state: StateModel,
) => Promise<void>;
/**
* Registry of all action handlers for a catalog
* @example
* const actions: Actions<typeof myCatalog> = {
* exit: async (params, setState) => { process.exit(0); },
* };
*/
export type Actions<C extends Catalog> = {
[K in keyof InferCatalogActions<C>]: ActionFn<C, K>;
};
-629
View File
@@ -1,629 +0,0 @@
import { z } from "zod";
// =============================================================================
// Standard Component Definitions for Ink (Terminal)
// =============================================================================
/**
* Standard component definitions for Ink terminal catalogs.
*
* These can be used directly or extended with custom components.
* All components are built using Ink core primitives only.
*/
export const standardComponentDefinitions = {
// ==========================================================================
// Layout Components (Ink Primitives)
// ==========================================================================
Box: {
props: z.object({
flexDirection: z
.enum(["row", "row-reverse", "column", "column-reverse"])
.nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
"space-evenly",
])
.nullable(),
flexGrow: z.number().nullable(),
flexShrink: z.number().nullable(),
flexWrap: z.enum(["nowrap", "wrap", "wrap-reverse"]).nullable(),
width: z.union([z.number().max(500), z.string()]).nullable(),
height: z.union([z.number().max(500), z.string()]).nullable(),
minWidth: z.union([z.number().max(500), z.string()]).nullable(),
minHeight: z.union([z.number().max(500), z.string()]).nullable(),
padding: z.number().nullable(),
paddingX: z.number().nullable(),
paddingY: z.number().nullable(),
paddingTop: z.number().nullable(),
paddingBottom: z.number().nullable(),
paddingLeft: z.number().nullable(),
paddingRight: z.number().nullable(),
margin: z.number().nullable(),
marginX: z.number().nullable(),
marginY: z.number().nullable(),
marginTop: z.number().nullable(),
marginBottom: z.number().nullable(),
marginLeft: z.number().nullable(),
marginRight: z.number().nullable(),
gap: z.number().nullable(),
columnGap: z.number().nullable(),
rowGap: z.number().nullable(),
borderStyle: z
.enum([
"single",
"double",
"round",
"bold",
"singleDouble",
"doubleSingle",
"classic",
])
.nullable(),
borderColor: z.string().nullable(),
borderTop: z.boolean().nullable(),
borderBottom: z.boolean().nullable(),
borderLeft: z.boolean().nullable(),
borderRight: z.boolean().nullable(),
borderDimColor: z.boolean().nullable(),
display: z.enum(["flex", "none"]).nullable(),
overflow: z.enum(["visible", "hidden"]).nullable(),
backgroundColor: z.string().nullable(),
}),
slots: ["default"],
description:
"Flexbox layout container (like a terminal <div>). Use for grouping, spacing, borders, and alignment. Default flexDirection is row.",
example: {
flexDirection: "column",
padding: 1,
gap: 1,
borderStyle: "round",
},
},
Text: {
props: z.object({
text: z.string(),
color: z.string().nullable(),
backgroundColor: z.string().nullable(),
bold: z.boolean().nullable(),
italic: z.boolean().nullable(),
underline: z.boolean().nullable(),
strikethrough: z.boolean().nullable(),
dimColor: z.boolean().nullable(),
inverse: z.boolean().nullable(),
wrap: z
.enum([
"wrap",
"truncate",
"truncate-end",
"truncate-middle",
"truncate-start",
])
.nullable(),
}),
slots: [],
description:
"Text output with optional styling (color, bold, italic, etc.). Use for all text content in the terminal.",
example: { text: "Hello, world!", bold: true, color: "green" },
},
Newline: {
props: z.object({
count: z.number().nullable(),
}),
slots: [],
description:
"Inserts one or more blank lines. Must be placed inside a Box with flexDirection column.",
example: { count: 1 },
},
Spacer: {
props: z.object({}),
slots: [],
description:
"Flexible empty space that expands to fill available room along the main axis. Use between elements to push them apart.",
},
// ==========================================================================
// Content Components (Higher-Level)
// ==========================================================================
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
color: z.string().nullable(),
}),
slots: [],
description:
"Section heading. h1 is bold + underlined, h2 is bold, h3 is bold + dimmed, h4 is dimmed.",
example: { text: "Dashboard", level: "h1" },
},
Divider: {
props: z.object({
character: z.string().nullable(),
color: z.string().nullable(),
dimColor: z.boolean().nullable(),
title: z.string().nullable(),
width: z.number().max(500).nullable(),
}),
slots: [],
description:
"Horizontal separator line. Default width is 40 characters. Optionally includes a centered title.",
example: { title: "Section", color: "gray", width: 40 },
},
Badge: {
props: z.object({
label: z.string(),
variant: z
.enum(["default", "info", "success", "warning", "error"])
.nullable(),
}),
slots: [],
description:
"Small colored inline label for status, counts, and categories.",
example: { label: "ACTIVE", variant: "success" },
},
Spinner: {
props: z.object({
label: z.string().nullable(),
color: z.string().nullable(),
}),
slots: [],
description:
"Animated loading spinner with optional label text. Uses braille animation characters.",
example: { label: "Loading...", color: "cyan" },
},
ProgressBar: {
props: z.object({
progress: z.number(),
width: z.number().max(500).nullable(),
color: z.string().nullable(),
label: z.string().nullable(),
}),
slots: [],
description:
"Horizontal progress bar. Set progress from 0 to 1. Default width is 30 characters.",
example: { progress: 0.65, width: 30, color: "green", label: "Uploading" },
},
Sparkline: {
props: z.object({
data: z.array(z.number()),
width: z.number().max(500).nullable(),
color: z.string().nullable(),
label: z.string().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
}),
slots: [],
description:
"Inline sparkline chart using Unicode block characters (▁▂▃▄▅▆▇█). Pass an array of numbers to visualize trends compactly. Set min/max to fix the scale across multiple sparklines.",
example: {
data: [3, 7, 2, 9, 4, 8, 1, 6, 5],
color: "cyan",
label: "CPU",
},
},
BarChart: {
props: z.object({
data: z.array(
z.object({
label: z.string(),
value: z.number(),
color: z.string().nullable(),
}),
),
width: z.number().max(500).nullable(),
showValues: z.boolean().nullable(),
showPercentage: z.boolean().nullable(),
}),
slots: [],
description:
"Horizontal bar chart. Each item has a label, numeric value, and optional color. Set showValues to display raw numbers, showPercentage to show % of total. Default bar width is 30.",
example: {
data: [
{ label: "TypeScript", value: 65, color: "blue" },
{ label: "Python", value: 20, color: "yellow" },
{ label: "Rust", value: 15, color: "red" },
],
showPercentage: true,
},
},
Table: {
props: z.object({
columns: z.array(
z.object({
header: z.string(),
key: z.string(),
width: z.number().max(200).nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
}),
),
rows: z.array(z.record(z.string(), z.string())),
borderStyle: z
.enum(["single", "double", "round", "bold", "classic"])
.nullable(),
backgroundColor: z.string().nullable(),
headerColor: z.string().nullable(),
}),
slots: [],
description:
"Tabular data display with headers and rows. Each row is a record mapping column keys to string values. Set both borderStyle and backgroundColor together so borders share the same shading.",
example: {
columns: [
{ header: "Name", key: "name", width: 20 },
{ header: "Status", key: "status", width: 10 },
],
rows: [
{ name: "api-server", status: "running" },
{ name: "worker", status: "stopped" },
],
headerColor: "cyan",
},
},
List: {
props: z.object({
items: z.array(z.string()),
ordered: z.boolean().nullable(),
bulletChar: z.string().nullable(),
spacing: z.number().nullable(),
}),
slots: [],
description:
"Bulleted or numbered list. Each item is a string. Use for simple enumerations.",
example: {
items: ["Install dependencies", "Run tests", "Deploy"],
ordered: true,
},
},
ListItem: {
props: z.object({
title: z.string(),
subtitle: z.string().nullable(),
leading: z.string().nullable(),
trailing: z.string().nullable(),
}),
slots: [],
description:
"Structured list row with title, optional subtitle, and leading/trailing text. Use with repeat for dynamic lists.",
example: {
title: "package.json",
subtitle: "Modified 2 hours ago",
leading: "*",
trailing: "2.1 KB",
},
},
Card: {
props: z.object({
title: z.string().nullable(),
backgroundColor: z.string().nullable(),
padding: z.number().nullable(),
}),
slots: ["default"],
description:
"Shaded container with optional title. Renders as a filled background area for grouping related content. Default background is a subtle dark shade.",
example: { title: "Details", padding: 1 },
},
KeyValue: {
props: z.object({
label: z.string(),
value: z.union([z.string(), z.number(), z.array(z.string())]),
labelColor: z.string().nullable(),
separator: z.string().nullable(),
}),
slots: [],
description:
"Key-value pair display. Renders label and value on the same line. Value can be a string, number, or array of strings (joined with commas). Default separator is a colon.",
example: { label: "Status", value: "Running", labelColor: "cyan" },
},
Link: {
props: z.object({
url: z.string(),
label: z.string().nullable(),
color: z.string().nullable(),
}),
slots: [],
description:
'Renders a URL as underlined text. If label is provided, shows "label (url)". Most terminals make URLs clickable automatically.',
example: { url: "https://github.com/vercel/next.js", label: "Next.js" },
},
StatusLine: {
props: z.object({
text: z.string(),
status: z.enum(["info", "success", "warning", "error"]).nullable(),
icon: z.string().nullable(),
}),
slots: [],
description:
"Status message with colored icon. Default icons: info=ℹ, success=✔, warning=⚠, error=✖.",
example: { text: "Build completed successfully", status: "success" },
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
detail: z.string().nullable(),
trend: z.enum(["up", "down", "neutral"]).nullable(),
}),
slots: [],
description:
"Key metric display with prominent value and optional trend indicator. Use for important numbers that deserve visual emphasis (price, temperature, stars, market cap).",
example: {
label: "Price",
value: "$70,686",
detail: "24h change",
trend: "up",
},
},
Callout: {
props: z.object({
type: z.enum(["info", "tip", "warning", "important"]).nullable(),
title: z.string().nullable(),
content: z.string(),
}),
slots: [],
description:
"Highlighted callout block with colored left border. Use for key takeaways, tips, warnings, or important notes that should stand out from surrounding content.",
example: {
type: "tip",
title: "Key Takeaway",
content:
"Revenue peaked in FY2022 driven by iPhone 13/14 upgrade cycles.",
},
},
Timeline: {
props: z.object({
items: z.array(
z.object({
title: z.string(),
description: z.string().nullable(),
date: z.string().nullable(),
status: z.enum(["completed", "current", "upcoming"]).nullable(),
}),
),
}),
slots: [],
description:
"Vertical timeline showing ordered events, steps, or milestones. Each item has a status-colored dot, title, optional date, and optional description.",
example: {
items: [
{
title: "Project Started",
description: "Initial commit and setup",
date: "Jan 2024",
status: "completed",
},
{
title: "Beta Release",
description: "Public beta launched",
date: "Mar 2024",
status: "current",
},
{ title: "v1.0", date: "Q2 2024", status: "upcoming" },
],
},
},
// ==========================================================================
// Interactive Components
// ==========================================================================
TextInput: {
props: z.object({
placeholder: z.string().nullable(),
value: z.string().nullable(),
label: z.string().nullable(),
mask: z.string().nullable(),
}),
events: ["submit", "change"],
slots: [],
description:
"Text input field for terminal. Use $bindState to bind to the state model for two-way binding. Press Enter to submit. Set mask to '*' for password fields.",
example: { placeholder: "Type here...", label: "Name" },
},
Select: {
props: z.object({
options: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
value: z.string().nullable(),
label: z.string().nullable(),
}),
events: ["change"],
slots: [],
description:
"Selection menu navigated with arrow keys. Use $bindState on value to bind the selected value to state. Press Enter to confirm selection.",
example: {
options: [
{ label: "Development", value: "dev" },
{ label: "Staging", value: "staging" },
{ label: "Production", value: "prod" },
],
label: "Environment",
},
},
MultiSelect: {
props: z.object({
options: z.array(
z.object({
label: z.string(),
value: z.string(),
}),
),
value: z.array(z.string()).nullable(),
label: z.string().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
}),
events: ["change", "submit"],
slots: [],
description:
"Multi-selection menu. Navigate with arrow keys, toggle with space, confirm with enter. Use $bindState on value to bind the selected values array to state. Set min/max to constrain selection count.",
example: {
options: [
{ label: "TypeScript", value: "ts" },
{ label: "Python", value: "py" },
{ label: "Rust", value: "rs" },
{ label: "Go", value: "go" },
],
label: "Languages",
},
},
ConfirmInput: {
props: z.object({
message: z.string().nullable(),
defaultValue: z.boolean().nullable(),
yesLabel: z.string().nullable(),
noLabel: z.string().nullable(),
}),
events: ["confirm", "deny"],
slots: [],
description:
"Yes/No confirmation prompt. Press Y to confirm, N to deny. Use for destructive or irreversible actions.",
example: {
message: "Delete all files?",
},
},
Tabs: {
props: z.object({
tabs: z.array(
z.object({
label: z.string(),
value: z.string(),
icon: z.string().nullable(),
}),
),
value: z.string().nullable(),
color: z.string().nullable(),
}),
events: ["change"],
slots: ["default"],
description:
"Tab bar navigation. Navigate with left/right arrow keys. Use $bindState on value to bind the active tab to state. Place child content inside and use visible conditions on children to show content for the active tab.",
example: {
tabs: [
{ label: "Overview", value: "overview" },
{ label: "Logs", value: "logs" },
{ label: "Settings", value: "settings" },
],
},
},
Markdown: {
props: z.object({
text: z.string(),
}),
slots: [],
description:
"Renders markdown-formatted text with proper terminal styling. Supports headings (#), **bold**, *italic*, `inline code`, ~~strikethrough~~, fenced code blocks, lists (ordered and unordered), blockquotes (>), and horizontal rules (---).",
example: {
text: "## Overview\n\nThis is **bold** and *italic* text with `inline code`.\n\n- First item\n- Second item\n\n> A blockquote",
},
},
};
// =============================================================================
// Standard Action Definitions for Ink
// =============================================================================
/**
* Standard action definitions for Ink terminal catalogs.
*/
export const standardActionDefinitions = {
setState: {
params: z.object({
statePath: z.string(),
value: z.unknown(),
}),
description: "Update a value in the state model at the given statePath.",
},
pushState: {
params: z.object({
statePath: z.string(),
value: z.unknown(),
clearStatePath: z.string().optional(),
}),
description:
'Append an item to an array in the state model. The value can contain { $state: "/statePath" } references and "$id" for auto IDs. Use clearStatePath to reset another path after pushing.',
},
removeState: {
params: z.object({
statePath: z.string(),
index: z.number(),
}),
description:
"Remove an item from an array in the state model at the given index.",
},
exit: {
params: z.object({
code: z.number().optional(),
}),
description:
"Exit the terminal application. Optional exit code (default 0).",
},
log: {
params: z.object({
message: z.string(),
}),
description:
"Write a message to stdout outside the Ink render. Useful for persistent output that should remain visible after the UI updates.",
},
};
// =============================================================================
// Types
// =============================================================================
/**
* Type for a component definition
*/
export type ComponentDefinition = {
props: z.ZodType;
slots: string[];
events?: string[];
description: string;
};
/**
* Type for an action definition
*/
export type ActionDefinition = {
params: z.ZodType;
description: string;
};
-1
View File
@@ -1 +0,0 @@
export { standardComponents } from "./standard";
File diff suppressed because it is too large Load Diff
-419
View File
@@ -1,419 +0,0 @@
import {
createContext,
useContext,
useState,
useCallback,
useMemo,
useRef,
type ReactNode,
} from "react";
import { Box, Text, useInput } from "ink";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
import { useFocusDisable } from "./focus";
/**
* Generate a unique ID for use with the "$id" token.
* Uses crypto.randomUUID when available, otherwise a timestamp + random suffix.
* No module-level mutable counter — safe across multiple render trees.
*/
function generateUniqueId(): string {
if (
typeof crypto !== "undefined" &&
typeof crypto.randomUUID === "function"
) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const MAX_RESOLVE_DEPTH = 10;
/**
* Deep-resolve dynamic value references within an object.
*
* Supported tokens:
* - `{ $state: "/statePath" }` - read a value from state
* - `"$id"` (string) or `{ "$id": true }` - generate a unique ID
*/
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
depth = 0,
): unknown {
if (depth > MAX_RESOLVE_DEPTH) return value;
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(obj)) {
resolved[key] = deepResolveValue(val, get, depth + 1);
}
return resolved;
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get, depth + 1));
}
return value;
}
/**
* Pending confirmation state
*/
export interface PendingConfirmation {
/** The resolved action */
action: ResolvedAction;
/** The action handler */
handler: ActionHandler;
/** Resolve callback */
resolve: () => void;
/** Reject callback */
reject: () => void;
}
/**
* Action context value
*/
export interface ActionContextValue {
/** Registered action handlers */
handlers: Record<string, ActionHandler>;
/** Actions currently executing (count of in-flight executions per action name) */
loadingActions: Map<string, number>;
/** Pending confirmation dialog */
pendingConfirmation: PendingConfirmation | null;
/** Execute an action binding */
execute: (binding: ActionBinding) => Promise<void>;
/** Confirm the pending action */
confirm: () => void;
/** Cancel the pending action */
cancel: () => void;
}
const ActionContext = createContext<ActionContextValue | null>(null);
/**
* Props for ActionProvider
*/
export interface ActionProviderProps {
/** Action handlers (custom handlers override built-in actions) */
handlers?: Record<string, ActionHandler>;
/** Navigation function */
navigate?: (path: string) => void;
children: ReactNode;
}
/**
* Provider for action execution
*/
export function ActionProvider({
handlers: initialHandlers = {},
navigate,
children,
}: ActionProviderProps) {
const { get, set, getSnapshot } = useStateStore();
// Ref always holds the latest prop-based handlers (avoids stale closures
// when createRenderer creates a new Proxy each render).
const initialHandlersRef = useRef(initialHandlers);
initialHandlersRef.current = initialHandlers;
const navigateRef = useRef(navigate);
navigateRef.current = navigate;
const [loadingActions, setLoadingActions] = useState<Map<string, number>>(
new Map(),
);
const [pendingConfirmation, setPendingConfirmation] =
useState<PendingConfirmation | null>(null);
// Ref tracks current pending confirmation so overlapping confirms can
// auto-reject the previous one without a stale closure.
const pendingRef = useRef<PendingConfirmation | null>(null);
const lookupHandler = useCallback(
(name: string): ActionHandler | undefined =>
initialHandlersRef.current[name],
[],
);
const execute = useCallback(
async (binding: ActionBinding) => {
const resolved = resolveAction(binding, getSnapshot());
// Check for custom handler override first — allows consumers to override
// built-in actions like setState, pushState, etc.
const customHandler = lookupHandler(resolved.action);
// Built-in: setState (overridable)
if (resolved.action === "setState" && !customHandler && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
}
return;
}
// Built-in: pushState (overridable)
if (
resolved.action === "pushState" &&
!customHandler &&
resolved.params
) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, get);
const raw = get(statePath);
const arr = Array.isArray(raw) ? raw : [];
set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
}
}
return;
}
// Built-in: removeState (overridable)
if (
resolved.action === "removeState" &&
!customHandler &&
resolved.params
) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const raw = get(statePath);
if (!Array.isArray(raw)) return;
set(
statePath,
raw.filter((_, i) => i !== index),
);
}
return;
}
// Built-in: log (overridable)
if (resolved.action === "log" && !customHandler) {
const message =
resolved.params?.message ?? resolved.params?.value ?? "";
console.log("[json-render]", message);
return;
}
// Built-in: exit (always delegates to handler if available)
if (resolved.action === "exit" && !customHandler) {
// No-op when no handler is provided
return;
}
const handler = customHandler;
if (!handler) {
console.warn(
`[json-render] No handler registered for action: ${resolved.action}`,
);
return;
}
// If confirmation is required, show dialog and wait for user response.
// Uses resolve(boolean) instead of reject() to avoid unhandled rejections.
if (resolved.confirm) {
// Auto-reject any existing pending confirmation so its promise resolves
// (prevents orphaned promises when a second confirm fires).
pendingRef.current?.reject();
const confirmed = await new Promise<boolean>((res) => {
const entry: PendingConfirmation = {
action: resolved,
handler,
resolve: () => {
pendingRef.current = null;
setPendingConfirmation(null);
res(true);
},
reject: () => {
pendingRef.current = null;
setPendingConfirmation(null);
res(false);
},
};
pendingRef.current = entry;
setPendingConfirmation(entry);
});
if (!confirmed) return;
}
const actionName = resolved.action;
setLoadingActions((prev) => {
const next = new Map(prev);
next.set(actionName, (next.get(actionName) ?? 0) + 1);
return next;
});
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate: navigateRef.current,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Map(prev);
const count = (next.get(actionName) ?? 1) - 1;
if (count <= 0) {
next.delete(actionName);
} else {
next.set(actionName, count);
}
return next;
});
}
},
[lookupHandler, get, set, getSnapshot],
);
// Use pendingRef for confirm/cancel to avoid stale closure issues
const confirm = useCallback(() => {
pendingRef.current?.resolve();
}, []);
const cancel = useCallback(() => {
pendingRef.current?.reject();
}, []);
const value = useMemo<ActionContextValue>(
() => ({
handlers: initialHandlers,
loadingActions,
pendingConfirmation,
execute,
confirm,
cancel,
}),
[
initialHandlers,
loadingActions,
pendingConfirmation,
execute,
confirm,
cancel,
],
);
return (
<ActionContext.Provider value={value}>{children}</ActionContext.Provider>
);
}
/**
* Hook to access action context
*/
export function useActions(): ActionContextValue {
const ctx = useContext(ActionContext);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
/**
* Hook for a single action binding — returns execute and loading state.
*/
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: boolean;
} {
const { execute, loadingActions } = useActions();
const executeAction = useCallback(() => execute(binding), [execute, binding]);
const isLoading = (loadingActions.get(binding.action) ?? 0) > 0;
return { execute: executeAction, isLoading };
}
/**
* Props for ConfirmDialog component
*/
export interface ConfirmDialogProps {
/** The confirmation config */
confirm: ActionConfirm;
/** Called when confirmed */
onConfirm: () => void;
/** Called when cancelled */
onCancel: () => void;
}
/**
* Terminal confirmation dialog using Ink's Box/Text and useInput.
* Press Y to confirm, N or Escape to cancel.
*/
export function ConfirmDialog({
confirm,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
const isDanger = confirm.variant === "danger";
// Suppress Tab cycling while modal is open
useFocusDisable(true);
// ConfirmDialog always captures input when mounted (it's modal)
useInput((input, key) => {
if (input.toLowerCase() === "y") {
onConfirm();
} else if (input.toLowerCase() === "n" || key.escape) {
onCancel();
}
});
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={isDanger ? "red" : "blue"}
paddingX={2}
paddingY={1}
>
<Text bold>{confirm.title}</Text>
<Text dimColor>{confirm.message}</Text>
<Box marginTop={1} gap={2}>
<Text>
<Text color={isDanger ? "red" : "green"} bold>
[Y]
</Text>{" "}
{confirm.confirmLabel ?? "Confirm"}
</Text>
<Text>
<Text bold>[N]</Text> {confirm.cancelLabel ?? "Cancel"}
</Text>
</Box>
</Box>
);
}
-168
View File
@@ -1,168 +0,0 @@
import React, {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useReducer,
useState,
} from "react";
import { useInput } from "ink";
// =============================================================================
// Focus context — tracks which interactive component receives keyboard input.
// Tab/Shift+Tab cycles focus. useInput's `isActive` gates input delivery.
// =============================================================================
interface FocusContextValue {
/** Register an interactive component by ID. */
register: (id: string) => void;
/** Unregister when unmounting. */
unregister: (id: string) => void;
/** The currently focused component's ID (or null). */
focusedId: string | null;
/** Check if a given ID is the focused component. */
isFocused: (id: string) => boolean;
/** Suppress Tab cycling (e.g. during modal dialogs). */
setDisabled: (disabled: boolean) => void;
}
const FocusContext = createContext<FocusContextValue | null>(null);
// Reducer state combines ids and focusedId for atomic updates.
interface FocusState {
ids: string[];
focusedId: string | null;
}
type FocusAction =
| { type: "register"; id: string }
| { type: "unregister"; id: string }
| { type: "cycle"; direction: "next" | "prev" };
function focusReducer(state: FocusState, action: FocusAction): FocusState {
switch (action.type) {
case "register": {
if (state.ids.includes(action.id)) return state;
const ids = [...state.ids, action.id];
// Auto-focus if first interactive component
const focusedId = state.focusedId ?? action.id;
return { ids, focusedId };
}
case "unregister": {
const ids = state.ids.filter((x) => x !== action.id);
let focusedId = state.focusedId;
if (focusedId === action.id) {
// Move focus to first remaining element, or null
focusedId = ids[0] ?? null;
}
return { ids, focusedId };
}
case "cycle": {
const { ids, focusedId } = state;
if (ids.length === 0) return { ...state, focusedId: null };
const idx = focusedId ? ids.indexOf(focusedId) : -1;
const next =
action.direction === "next"
? ids[(idx + 1) % ids.length]!
: ids[(idx - 1 + ids.length) % ids.length]!;
return { ...state, focusedId: next };
}
}
}
export function FocusProvider({ children }: { children: React.ReactNode }) {
const [state, dispatch] = useReducer(focusReducer, {
ids: [],
focusedId: null,
});
const [disableCount, setDisableCount] = useState(0);
const disabled = disableCount > 0;
// Counter-based disable: multiple concurrent callers (e.g. nested modals)
// each increment on mount and decrement on unmount. Focus is only re-enabled
// when all callers have released.
const setDisabled = useCallback((value: boolean) => {
setDisableCount((prev) => (value ? prev + 1 : Math.max(0, prev - 1)));
}, []);
const register = useCallback(
(id: string) => dispatch({ type: "register", id }),
[],
);
const unregister = useCallback(
(id: string) => dispatch({ type: "unregister", id }),
[],
);
// When disabled (e.g. modal dialog open), all components report isActive=false
// so their useInput({ isActive }) stops processing keystrokes.
const isFocused = useCallback(
(id: string) => !disabled && state.focusedId === id,
[state.focusedId, disabled],
);
// Tab / Shift+Tab to cycle focus (suppressed when disabled, e.g. during modals)
useInput((_input, key) => {
if (disabled) return;
if (!key.tab) return;
dispatch({ type: "cycle", direction: key.shift ? "prev" : "next" });
});
const value = useMemo(
() => ({
register,
unregister,
focusedId: state.focusedId,
isFocused,
setDisabled,
}),
[register, unregister, state.focusedId, isFocused, setDisabled],
);
return (
<FocusContext.Provider value={value}>{children}</FocusContext.Provider>
);
}
/**
* Hook for interactive components. Registers on mount via useEffect,
* unregisters on unmount. Returns `isActive` boolean for gating `useInput`.
*
* Uses React.useId() for stable, instance-scoped IDs (no module-level counters).
*/
export function useFocus(): { isActive: boolean; id: string } {
const ctx = useContext(FocusContext);
const id = useId();
useEffect(() => {
ctx?.register(id);
return () => {
ctx?.unregister(id);
};
}, [ctx, id]);
const isActive = ctx?.isFocused(id) ?? false;
return { isActive, id };
}
/**
* Hook to suppress/restore Tab cycling.
* Used by modal dialogs (e.g. ConfirmDialog) to prevent background focus changes.
*
* Tracks the disabled value at effect time via a ref to avoid double-decrement
* when the `disabled` prop toggles from true to false.
*/
export function useFocusDisable(disabled: boolean): void {
const ctx = useContext(FocusContext);
useEffect(() => {
if (!disabled) return;
ctx?.setDisabled(true);
return () => {
ctx?.setDisabled(false);
};
}, [ctx, disabled]);
}
@@ -1,42 +0,0 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
/**
* Repeat scope value provided to child elements inside a repeated element.
*/
export interface RepeatScopeValue {
/** The current array item object */
item: unknown;
/** Index of the current item in the array */
index: number;
/** Absolute state path to the current array item (e.g. "/todos/0") — used for statePath two-way binding */
basePath: string;
}
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
/**
* Provides repeat scope to child elements so $item and $index expressions resolve correctly.
*/
export function RepeatScopeProvider({
item,
index,
basePath,
children,
}: RepeatScopeValue & { children: ReactNode }) {
const value = useMemo(
() => ({ item, index, basePath }),
[item, index, basePath],
);
return (
<RepeatScopeContext.Provider value={value}>
{children}
</RepeatScopeContext.Provider>
);
}
/**
* Read the current repeat scope (or null if not inside a repeated element).
*/
export function useRepeatScope(): RepeatScopeValue | null {
return useContext(RepeatScopeContext);
}
-49
View File
@@ -1,49 +0,0 @@
import { describe, it, expect } from "vitest";
import React from "react";
import { renderHook, act } from "@testing-library/react";
import {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
} from "./state";
describe("state re-exports (smoke test)", () => {
it("StateProvider + useStateStore round-trip", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
);
const { result } = renderHook(() => useStateStore(), { wrapper });
expect(result.current.get("/count")).toBe(0);
act(() => {
result.current.set("/count", 42);
});
expect(result.current.state.count).toBe(42);
});
it("useStateValue reads from state", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
);
const { result } = renderHook(() => useStateValue("/name"), { wrapper });
expect(result.current).toBe("Alice");
});
it("useStateBinding returns value and setter", () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
);
const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
const [value, setValue] = result.current;
expect(value).toBe(1);
expect(typeof setValue).toBe("function");
});
});

Some files were not shown because too many files have changed in this diff Show More