Compare commits

...
Author SHA1 Message Date
github-actions[bot] 9adcc09204 chore: version packages (#248)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-27 18:17:20 -07:00
Chris Tate 519a538aae v0.16.0 (#247) 2026-03-27 16:19:38 -07:00
Chris Tate a99d68c481 next (#246)
* next

* next example

* component catalog

* fixes

* fixes
2026-03-27 16:10:13 -07:00
maxffarrellandClaude Opus 4.6 40892a6af0 feat: shadcn-svelte renderer (#227)
* init

* fix: resolve all type errors, runtime bugs, and test failures in @json-render/shadcn-svelte

- Add /index.js extensions to all ../ui/* imports (NodeNext moduleResolution)
- Type shadcnComponents as Record<string, Component<any>> to fix d.ts generation
- Call getBoundProp() once at top level instead of inside a binding() function
  called from event handlers (getContext() only works during initialization)
- Move getOptionalValidationContext() call to top level; update createValidation()
  to accept the context as first param instead of calling getContext() itself
- Wrap validation.register() in untrack() to break the fieldConfigs read-write
  cycle that caused effect_update_depth_exceeded in tests
- Fix implicit any on Input onkeydown handler (KeyboardEvent type)
- Use untrack() for $state() initializers that read reactive props values
- Call getStateValue() once at top level in Dialog/Drawer (not in a function
  called from event handlers)
- Fix test fixtures: use named imports { StateProvider, ValidationProvider }
- Add server.deps.inline for bits-ui and @lucide/svelte so vitest can process
  their .svelte source files; add root svelte.config.js with runes: true
- Add role/aria-modal/tabindex/onkeydown a11y attributes to Dialog and Drawer
- Fix self-closing non-void elements in Skeleton, Spinner, Textarea

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* update shadcn-svelte components

* fixes

* fixes

* fix: address PR review issues for shadcn-svelte renderer

- Fix missing comma in .changeset/config.json (invalid JSON)
- Fix Slider.svelte type errors (single-mode expects number, not array)
- Replace raw <button> with shadcn Button in Pagination.svelte
- Remove global font import and body/html overrides from app.css
- Remove root svelte.config.js (duplicates package-level config)
- Revert unrelated packageManager version bump

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:38:11 -05:00
github-actions[bot] 7628640984 chore: version packages (#243)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-23 13:30:11 -05:00
Chris Tate bf3a7ec61d prepare v0.15 release (#242) 2026-03-23 13:23:41 -05:00
Chris Tate 453484985a ink updates (#241)
* ink updates

* no emojis

* design

* fit content

* fixes

* fixes
2026-03-23 13:12:31 -05:00
337 changed files with 11963 additions and 314 deletions
+3 -1
View File
@@ -23,7 +23,9 @@
"@json-render/solid", "@json-render/solid",
"@json-render/react-three-fiber", "@json-render/react-three-fiber",
"@json-render/yaml", "@json-render/yaml",
"@json-render/ink" "@json-render/shadcn-svelte",
"@json-render/ink",
"@json-render/next"
] ]
], ],
"linked": [], "linked": [],
+78
View File
@@ -25,6 +25,8 @@ npm install @json-render/core @json-render/svelte
npm install @json-render/core @json-render/solid npm install @json-render/core @json-render/solid
# or for terminal UIs # or for terminal UIs
npm install @json-render/core @json-render/ink ink react 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 # or for 3D scenes
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
``` ```
@@ -125,8 +127,10 @@ function Dashboard({ spec }) {
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity | | `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts | | `@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` | 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-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/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/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs | | `@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/react-email` | React Email renderer for HTML/plain-text emails from specs |
@@ -482,6 +486,80 @@ 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) ### Ink (Terminal)
```tsx ```tsx
+20
View File
@@ -1,5 +1,25 @@
# web # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+280
View File
@@ -0,0 +1,280 @@
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>
@@ -0,0 +1,320 @@
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`
+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 GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/ink, @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 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, ink, 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. 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.
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. 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.
+2
View File
@@ -69,9 +69,11 @@ export const docsNavigation: NavSection[] = [
items: [ items: [
{ title: "@json-render/core", href: "/docs/api/core" }, { title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" }, { title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/next", href: "/docs/api/next" },
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" }, { title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
{ title: "@json-render/react-email", href: "/docs/api/react-email" }, { title: "@json-render/react-email", href: "/docs/api/react-email" },
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" }, { 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/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/image", href: "/docs/api/image" }, { title: "@json-render/image", href: "/docs/api/image" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" }, { title: "@json-render/remotion", href: "/docs/api/remotion" },
+2
View File
@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
// API references // API references
"docs/api/core": "@json-render/core API", "docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API", "docs/api/react": "@json-render/react API",
"docs/api/next": "@json-render/next API",
"docs/api/vue": "@json-render/vue API", "docs/api/vue": "@json-render/vue API",
"docs/api/solid": "@json-render/solid API", "docs/api/solid": "@json-render/solid API",
"docs/api/react-pdf": "@json-render/react-pdf API", "docs/api/react-pdf": "@json-render/react-pdf API",
@@ -53,6 +54,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/image": "@json-render/image API", "docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API", "docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn 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/mcp": "@json-render/mcp API",
"docs/api/redux": "@json-render/redux API", "docs/api/redux": "@json-render/redux API",
"docs/api/zustand": "@json-render/zustand API", "docs/api/zustand": "@json-render/zustand API",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "web", "name": "web",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
+18
View File
@@ -1,5 +1,23 @@
# example-chat # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-chat", "name": "example-chat",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+18
View File
@@ -1,5 +1,23 @@
# example-dashboard # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-dashboard", "name": "example-dashboard",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+21
View File
@@ -0,0 +1,21 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-game-engine", "name": "example-game-engine",
"version": "0.1.0", "version": "0.1.2",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-image # 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 ## 0.1.6
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-image", "name": "example-image",
"version": "0.1.6", "version": "0.1.8",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+17
View File
@@ -0,0 +1,17 @@
# 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-ink-chat", "name": "example-ink-chat",
"version": "0.1.0", "version": "0.1.2",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+81 -31
View File
@@ -85,29 +85,72 @@ function getStepHint(type: string, isLast: boolean): string {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// System prompt — embeds catalog documentation so the AI knows what components // System prompt — handwritten design guidance + catalog documentation.
// are available and how to output JSONL patches. // Follows the same pattern as examples/chat/lib/agent.ts: a rich
// AGENT_INSTRUCTIONS string with catalog.prompt() appended at the end.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const SYSTEM_PROMPT = catalog.prompt({ 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.
system:
"You are a terminal chat assistant. Use tools for real-time data (web_search, get_weather, get_hacker_news, get_github_repo, get_crypto_price), then render results as rich terminal UIs.", 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", mode: "inline",
customRules: [ customRules: [
"ALL text MUST go inside the spec using the Markdown component. NEVER write prose, summaries, or explanations outside the ```spec fence. The ONLY text allowed outside the fence is a single short status like 'Searching…' while tools run.", "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 (greetings, clarifications), still output a ```spec with a Markdown component.", "For text-only answers, still output a spec with a Markdown component.",
"Prefer Table for structured data (forecasts, leaderboards, language breakdowns) and KeyValue for label-value pairs (stats, metadata).", "Prefer Table for structured data and KeyValue for label-value pairs.",
"NEVER use emojis anywhere in your output. Plain text only.",
// Interactive components
"INTERACTIVE UIs: You can create interactive forms, surveys, and selection interfaces that the user actually interacts with in the terminal. The user navigates with arrow keys, selects with Space/Enter, and types into text fields.",
"When creating interactive UIs, ALWAYS include a submit action. Add an element (usually a Text or StatusLine) that tells the user to press Enter or a specific key to submit. Wire up the submit event to a 'submit' action — the app will collect the form state and send it back to you automatically.",
"For interactive specs, ALWAYS populate the state field with sensible defaults for all bound values. Example: if a Select binds to /framework, include state: { framework: 'react' }.",
'IMPORTANT: Use $bindState on interactive components so their values are written back to state. Example for Select: { "value": { "$state": "/choice" }, "$bindState": { "value": "/choice" } }.',
"For surveys/quizzes with multiple sections, use Tabs to organize them. Bind the active tab to state and use visible conditions on children.",
"For confirmation prompts, use ConfirmInput with a clear message. The confirm/deny events can trigger actions.",
"After receiving submitted form data, acknowledge the user's choices and respond to them — don't just repeat what they selected.",
], ],
}); })}`;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Types // Types
@@ -236,7 +279,6 @@ function MessageView({ message }: { message: Message }) {
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold>AI:</Text>
{message.spec ? ( {message.spec ? (
<JSONUIProvider initialState={message.spec.state ?? {}}> <JSONUIProvider initialState={message.spec.state ?? {}}>
<DisableFocus /> <DisableFocus />
@@ -317,9 +359,6 @@ function LiveInteractiveSpec({
if (interactiveKeys.length === 0) { if (interactiveKeys.length === 0) {
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold color="green">
AI:
</Text>
<JSONUIProvider store={store} handlers={handlers}> <JSONUIProvider store={store} handlers={handlers}>
<Renderer spec={spec} /> <Renderer spec={spec} />
</JSONUIProvider> </JSONUIProvider>
@@ -331,9 +370,6 @@ function LiveInteractiveSpec({
return ( return (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold color="green">
AI:
</Text>
{interactiveKeys.length > 1 && ( {interactiveKeys.length > 1 && (
<Text dimColor> <Text dimColor>
Step {step + 1} of {interactiveKeys.length} Step {step + 1} of {interactiveKeys.length}
@@ -417,7 +453,7 @@ export function App() {
try { try {
const result = streamText({ const result = streamText({
model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL), model: gateway(process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL),
system: SYSTEM_PROMPT, system: AGENT_INSTRUCTIONS,
messages: history, messages: history,
temperature: 0.7, temperature: 0.7,
abortSignal: controller.signal, abortSignal: controller.signal,
@@ -535,11 +571,26 @@ export function App() {
return ( return (
<Box flexDirection="column" padding={1} minHeight={stdout.rows}> <Box flexDirection="column" padding={1} minHeight={stdout.rows}>
{/* Header */} {/* Header */}
<Box marginBottom={1}> <Box marginBottom={1} gap={1}>
<Text bold>json-render terminal chat</Text> <Text bold color="cyan">
<Text dimColor> (Ctrl+C to exit)</Text> json-render
</Text>
<Text dimColor>Ctrl+C to exit</Text>
</Box> </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 */} {/* Message history — collapsed when interactive wizard is active */}
{liveSpec && !isStreaming ? ( {liveSpec && !isStreaming ? (
<> <>
@@ -568,10 +619,9 @@ export function App() {
{/* Live spec preview while streaming */} {/* Live spec preview while streaming */}
{isStreaming && streamingSpec && streamingSpec.root && ( {isStreaming && streamingSpec && streamingSpec.root && (
<Box flexDirection="column" marginBottom={1}> <Box flexDirection="column" marginBottom={1}>
<Text bold>AI:</Text>
<JSONUIProvider initialState={streamingSpec.state ?? {}}> <JSONUIProvider initialState={streamingSpec.state ?? {}}>
<DisableFocus /> <DisableFocus />
<Renderer spec={streamingSpec} /> <Renderer spec={streamingSpec} loading />
</JSONUIProvider> </JSONUIProvider>
</Box> </Box>
)} )}
+2 -1
View File
@@ -275,7 +275,8 @@ export const getGitHubRepo = tool({
const languageBreakdown = Object.entries(languages) const languageBreakdown = Object.entries(languages)
.map(([lang, bytes]) => ({ .map(([lang, bytes]) => ({
language: lang, language: lang,
percentage: Math.round((bytes / totalBytes) * 100), percentage:
totalBytes > 0 ? Math.round((bytes / totalBytes) * 100) : 0,
})) }))
.sort((a, b) => b.percentage - a.percentage) .sort((a, b) => b.percentage - a.percentage)
.slice(0, 6); .slice(0, 6);
+20
View File
@@ -1,5 +1,25 @@
# example-mcp # 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 ## 0.1.5
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-mcp", "name": "example-mcp",
"version": "0.1.5", "version": "0.1.7",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@@ -0,0 +1,11 @@
# 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
@@ -0,0 +1,12 @@
"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>;
}
@@ -0,0 +1,15 @@
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} />;
}
@@ -0,0 +1,7 @@
"use client";
import { PageRenderer, type PageRendererProps } from "@json-render/next";
export function WebsiteRenderer(props: PageRendererProps) {
return <PageRenderer {...props} />;
}
@@ -0,0 +1,11 @@
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 });
}
@@ -0,0 +1,5 @@
import { Editor } from "@/components/editor";
export default function BuilderPage() {
return <Editor />;
}
@@ -0,0 +1,101 @@
@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;
}
}
@@ -0,0 +1,23 @@
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>
);
}
@@ -0,0 +1,180 @@
"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>
);
}
@@ -0,0 +1,71 @@
"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>
);
}
@@ -0,0 +1,53 @@
"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
@@ -0,0 +1,7 @@
import { createNextApp } from "@json-render/next/server";
import { getSpec } from "./spec-store";
export const { getPageData, generateMetadata, generateStaticParams } =
createNextApp({
spec: () => getSpec(),
});
@@ -0,0 +1,12 @@
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: {},
});
@@ -0,0 +1,307 @@
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",
},
},
},
},
},
},
};
@@ -0,0 +1,14 @@
"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: {},
});
@@ -0,0 +1,12 @@
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;
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
@@ -0,0 +1,188 @@
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"]>;
@@ -0,0 +1,501 @@
"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
@@ -0,0 +1,6 @@
/// <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.
@@ -0,0 +1,12 @@
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;
@@ -0,0 +1,41 @@
{
"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"
}
}
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
@@ -0,0 +1,13 @@
{
"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,5 +1,23 @@
# example-no-ai # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-no-ai", "name": "example-no-ai",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-react-email # 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 ## 0.1.5
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-email", "name": "example-react-email",
"version": "0.1.5", "version": "0.1.7",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-react-native # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-native", "name": "example-react-native",
"version": "0.1.9", "version": "0.1.11",
"private": true, "private": true,
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-react-pdf # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-pdf", "name": "example-react-pdf",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+18
View File
@@ -1,5 +1,23 @@
# example-react-three-fiber # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-react-three-fiber", "name": "example-react-three-fiber",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-remotion # 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 ## 0.1.9
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-remotion", "name": "example-remotion",
"version": "0.1.9", "version": "0.1.11",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-solid # 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 ## 0.1.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-solid", "name": "example-solid",
"version": "0.1.3", "version": "0.1.5",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,5 +1,21 @@
# com.example.json-render-demo # 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 ## 0.0.10
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "com.example.json-render-demo", "name": "com.example.json-render-demo",
"version": "0.0.10", "version": "0.0.12",
"description": "Test", "description": "Test",
"private": true, "private": true,
"license": "~~proprietary~~", "license": "~~proprietary~~",
@@ -1,5 +1,21 @@
# com.example.json-render-fullpage-demo # 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 ## 0.0.10
### Patch Changes ### Patch Changes
@@ -1,6 +1,6 @@
{ {
"name": "com.example.json-render-fullpage-demo", "name": "com.example.json-render-fullpage-demo",
"version": "0.0.10", "version": "0.0.12",
"description": "Full-page Stripe App example (alpha)", "description": "Full-page Stripe App example (alpha)",
"private": true, "private": true,
"license": "~~proprietary~~", "license": "~~proprietary~~",
+16
View File
@@ -1,5 +1,21 @@
# svelte-chat # 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 ## 0.0.6
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "svelte-chat", "name": "svelte-chat",
"private": true, "private": true,
"version": "0.0.6", "version": "0.0.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+16
View File
@@ -1,5 +1,21 @@
# example-svelte # 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 ## 0.1.6
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-svelte", "name": "example-svelte",
"version": "0.1.6", "version": "0.1.8",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+22
View File
@@ -1,5 +1,27 @@
# vite-renderers # 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 ## 0.1.7
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "vite-renderers", "name": "vite-renderers",
"version": "0.1.7", "version": "0.1.9",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+16
View File
@@ -1,5 +1,21 @@
# example-vue # 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 ## 0.1.7
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "example-vue", "name": "example-vue",
"version": "0.1.7", "version": "0.1.9",
"private": true, "private": true,
"scripts": { "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)", "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,5 +1,19 @@
# @json-render/codegen # @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 ## 0.14.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/codegen", "name": "@json-render/codegen",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees", "description": "Utilities for generating code from json-render UI trees",
"keywords": [ "keywords": [
+22
View File
@@ -1,5 +1,27 @@
# @json-render/core # @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 ## 0.14.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/core", "name": "@json-render/core",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.", "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [ "keywords": [
+14
View File
@@ -1,5 +1,19 @@
# @json-render/image # @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 ## 0.14.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/image", "name": "@json-render/image",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.", "description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [ "keywords": [
+25
View File
@@ -0,0 +1,25 @@
# @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
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/ink", "name": "@json-render/ink",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.", "description": "Ink terminal renderer for @json-render/core. JSON becomes terminal UIs.",
"keywords": [ "keywords": [
@@ -57,7 +57,8 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@json-render/core": "workspace:*", "@json-render/core": "workspace:*",
+77 -11
View File
@@ -259,11 +259,12 @@ export const standardComponentDefinitions = {
borderStyle: z borderStyle: z
.enum(["single", "double", "round", "bold", "classic"]) .enum(["single", "double", "round", "bold", "classic"])
.nullable(), .nullable(),
backgroundColor: z.string().nullable(),
headerColor: z.string().nullable(), headerColor: z.string().nullable(),
}), }),
slots: [], slots: [],
description: description:
"Tabular data display with headers and rows. Each row is a record mapping column keys to string values.", "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: { example: {
columns: [ columns: [
{ header: "Name", key: "name", width: 20 }, { header: "Name", key: "name", width: 20 },
@@ -306,7 +307,7 @@ export const standardComponentDefinitions = {
example: { example: {
title: "package.json", title: "package.json",
subtitle: "Modified 2 hours ago", subtitle: "Modified 2 hours ago",
leading: "📄", leading: "*",
trailing: "2.1 KB", trailing: "2.1 KB",
}, },
}, },
@@ -314,16 +315,13 @@ export const standardComponentDefinitions = {
Card: { Card: {
props: z.object({ props: z.object({
title: z.string().nullable(), title: z.string().nullable(),
borderStyle: z backgroundColor: z.string().nullable(),
.enum(["single", "double", "round", "bold", "classic"])
.nullable(),
borderColor: z.string().nullable(),
padding: z.number().nullable(), padding: z.number().nullable(),
}), }),
slots: ["default"], slots: ["default"],
description: description:
"Bordered container with optional title. Use for grouping related content with a visual boundary.", "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", borderStyle: "round", padding: 1 }, example: { title: "Details", padding: 1 },
}, },
KeyValue: { KeyValue: {
@@ -363,6 +361,74 @@ export const standardComponentDefinitions = {
example: { text: "Build completed successfully", status: "success" }, 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 // Interactive Components
// ========================================================================== // ==========================================================================
@@ -468,9 +534,9 @@ export const standardComponentDefinitions = {
"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.", "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: { example: {
tabs: [ tabs: [
{ label: "Overview", value: "overview", icon: "📊" }, { label: "Overview", value: "overview" },
{ label: "Logs", value: "logs", icon: "📋" }, { label: "Logs", value: "logs" },
{ label: "Settings", value: "settings", icon: "⚙" }, { label: "Settings", value: "settings" },
], ],
}, },
}, },
+160 -21
View File
@@ -325,6 +325,7 @@ function TableComponent({ element }: ComponentRenderProps) {
}>; }>;
rows?: Array<Record<string, string>>; rows?: Array<Record<string, string>>;
borderStyle?: string; borderStyle?: string;
backgroundColor?: string;
headerColor?: string; headerColor?: string;
}; };
@@ -363,7 +364,12 @@ function TableComponent({ element }: ComponentRenderProps) {
| undefined; | undefined;
return ( return (
<Box flexDirection="column" borderStyle={borderStyleProp}> <Box
flexDirection="column"
alignSelf="flex-start"
borderStyle={borderStyleProp}
backgroundColor={p.backgroundColor}
>
{/* Header */} {/* Header */}
<Box> <Box>
{columns.map((col, i) => ( {columns.map((col, i) => (
@@ -375,15 +381,19 @@ function TableComponent({ element }: ComponentRenderProps) {
{/* Separator */} {/* Separator */}
<Text dimColor>{colWidths.map((w) => "─".repeat(w)).join("─")}</Text> <Text dimColor>{colWidths.map((w) => "─".repeat(w)).join("─")}</Text>
{/* Rows */} {/* Rows */}
{rows.map((row, rowIdx) => ( {rows.map((row, rowIdx) => {
<Box key={rowIdx}> const rowKey =
columns.map((c) => row[c.key] ?? "").join("\0") || String(rowIdx);
return (
<Box key={rowKey} gap={0}>
{columns.map((col, i) => ( {columns.map((col, i) => (
<Text key={col.key}> <Text key={col.key}>
{padCell(row[col.key] || "—", colWidths[i]!, col.align)} {padCell(row[col.key] || "—", colWidths[i]!, col.align)}
</Text> </Text>
))} ))}
</Box> </Box>
))} );
})}
</Box> </Box>
); );
} }
@@ -402,7 +412,7 @@ function ListComponent({ element }: ComponentRenderProps) {
return ( return (
<Box flexDirection="column" gap={p.spacing ?? 0}> <Box flexDirection="column" gap={p.spacing ?? 0}>
{items.map((item, i) => ( {items.map((item, i) => (
<Box key={i} gap={1}> <Box key={`${i}:${item}`} gap={1}>
<Text dimColor>{p.ordered ? `${i + 1}.` : bullet}</Text> <Text dimColor>{p.ordered ? `${i + 1}.` : bullet}</Text>
<Text>{item}</Text> <Text>{item}</Text>
</Box> </Box>
@@ -434,16 +444,15 @@ function ListItemComponent({ element }: ComponentRenderProps) {
function CardComponent({ element, children }: ComponentRenderProps) { function CardComponent({ element, children }: ComponentRenderProps) {
const p = element.props as { const p = element.props as {
title?: string; title?: string;
borderStyle?: "single" | "double" | "round" | "bold" | "classic"; backgroundColor?: string;
borderColor?: string;
padding?: number; padding?: number;
}; };
return ( return (
<Box <Box
flexDirection="column" flexDirection="column"
borderStyle={p.borderStyle ?? "round"} alignSelf="flex-start"
borderColor={p.borderColor} backgroundColor={p.backgroundColor ?? "#1a1a1a"}
padding={p.padding ?? 1} padding={p.padding ?? 1}
> >
{p.title ? ( {p.title ? (
@@ -537,10 +546,136 @@ function StatusLineComponent({ element }: ComponentRenderProps) {
); );
} }
// =============================================================================
// Metric, Callout, Timeline
// =============================================================================
const TREND_CONFIG: Record<string, { prefix: string; color: string }> = {
up: { prefix: "+", color: "green" },
down: { prefix: "", color: "red" },
neutral: { prefix: "~", color: "gray" },
};
function MetricComponent({ element }: ComponentRenderProps) {
const p = element.props as {
label?: string;
value?: string;
detail?: string;
trend?: "up" | "down" | "neutral";
};
const trend = p.trend ? TREND_CONFIG[p.trend] : null;
return (
<Box flexDirection="column">
<Text dimColor>{p.label ?? ""}</Text>
<Box gap={1}>
<Text bold>{p.value ?? ""}</Text>
{trend && p.detail ? (
<Text color={trend.color}>
{trend.prefix}
{p.detail}
</Text>
) : null}
</Box>
{!trend && p.detail ? <Text dimColor>{p.detail}</Text> : null}
</Box>
);
}
const CALLOUT_CONFIG: Record<string, { label: string; color: string }> = {
info: { label: "INFO", color: "blue" },
tip: { label: "TIP", color: "green" },
warning: { label: "WARNING", color: "yellow" },
important: { label: "IMPORTANT", color: "magenta" },
};
function CalloutComponent({ element }: ComponentRenderProps) {
const p = element.props as {
type?: "info" | "tip" | "warning" | "important";
title?: string;
content?: string;
};
const config = CALLOUT_CONFIG[p.type ?? "info"] ?? CALLOUT_CONFIG.info!;
return (
<Box
flexDirection="column"
borderStyle="bold"
borderLeft
borderRight={false}
borderTop={false}
borderBottom={false}
borderColor={config.color}
paddingLeft={1}
>
{p.title ? (
<Text bold color={config.color}>
{p.title}
</Text>
) : null}
<Text>{p.content ?? ""}</Text>
</Box>
);
}
const TIMELINE_DOT: Record<string, { dot: string; color: string }> = {
completed: { dot: "●", color: "green" },
current: { dot: "◆", color: "cyan" },
upcoming: { dot: "○", color: "gray" },
};
function TimelineComponent({ element }: ComponentRenderProps) {
const p = element.props as {
items?: Array<{
title?: string;
description?: string;
date?: string;
status?: "completed" | "current" | "upcoming";
}>;
};
const items = p.items ?? [];
return (
<Box flexDirection="column" gap={1}>
{items.map((item, i) => {
const cfg =
TIMELINE_DOT[item.status ?? "upcoming"] ?? TIMELINE_DOT.upcoming!;
return (
<Box key={i} flexDirection="column">
<Box gap={1}>
<Text color={cfg.color}>{cfg.dot}</Text>
<Text bold>{item.title ?? ""}</Text>
{item.date ? <Text dimColor>{item.date}</Text> : null}
</Box>
{item.description ? (
<Box paddingLeft={2}>
<Text dimColor>{item.description}</Text>
</Box>
) : null}
</Box>
);
})}
</Box>
);
}
// ============================================================================= // =============================================================================
// Interactive Components // Interactive Components
// ============================================================================= // =============================================================================
/** Count code points instead of UTF-16 code units (handles emoji/surrogate pairs). */
function codePointLength(s: string): number {
return Array.from(s).length;
}
/** Slice by code point index instead of UTF-16 offset. */
function codePointSlice(s: string, start: number, end?: number): string {
return Array.from(s).slice(start, end).join("");
}
function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) { function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
const p = element.props as { const p = element.props as {
placeholder?: string; placeholder?: string;
@@ -556,11 +691,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
); );
const currentValue = value ?? ""; const currentValue = value ?? "";
const [cursorPos, setCursorPos] = useState(currentValue.length); const [cursorPos, setCursorPos] = useState(codePointLength(currentValue));
// Keep cursor in bounds when value changes externally // Keep cursor in bounds when value changes externally
useEffect(() => { useEffect(() => {
setCursorPos((prev) => Math.min(prev, currentValue.length)); setCursorPos((prev) => Math.min(prev, codePointLength(currentValue)));
}, [currentValue]); }, [currentValue]);
useInput( useInput(
@@ -573,8 +708,8 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
if (key.backspace || key.delete) { if (key.backspace || key.delete) {
if (cursorPos > 0) { if (cursorPos > 0) {
const newVal = const newVal =
currentValue.slice(0, cursorPos - 1) + codePointSlice(currentValue, 0, cursorPos - 1) +
currentValue.slice(cursorPos); codePointSlice(currentValue, cursorPos);
setValue(newVal); setValue(newVal);
setCursorPos((prev) => prev - 1); setCursorPos((prev) => prev - 1);
emit("change"); emit("change");
@@ -588,7 +723,9 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
return; return;
} }
if (key.rightArrow) { if (key.rightArrow) {
setCursorPos((prev) => Math.min(currentValue.length, prev + 1)); setCursorPos((prev) =>
Math.min(codePointLength(currentValue), prev + 1),
);
return; return;
} }
@@ -598,11 +735,11 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
if (input) { if (input) {
const newVal = const newVal =
currentValue.slice(0, cursorPos) + codePointSlice(currentValue, 0, cursorPos) +
input + input +
currentValue.slice(cursorPos); codePointSlice(currentValue, cursorPos);
setValue(newVal); setValue(newVal);
setCursorPos((prev) => prev + input.length); setCursorPos((prev) => prev + codePointLength(input));
emit("change"); emit("change");
} }
}, },
@@ -611,13 +748,12 @@ function TextInputComponent({ element, emit, bindings }: ComponentRenderProps) {
const displayValue = currentValue const displayValue = currentValue
? p.mask ? p.mask
? p.mask.repeat(currentValue.length) ? p.mask.repeat(codePointLength(currentValue))
: currentValue : currentValue
: ""; : "";
// Render with cursor position indicator const before = codePointSlice(displayValue, 0, cursorPos);
const before = displayValue.slice(0, cursorPos); const after = codePointSlice(displayValue, cursorPos);
const after = displayValue.slice(cursorPos);
return ( return (
<Box gap={1}> <Box gap={1}>
@@ -1178,6 +1314,9 @@ export const standardComponents: ComponentRegistry = {
KeyValue: KeyValueComponent, KeyValue: KeyValueComponent,
Link: LinkComponent, Link: LinkComponent,
StatusLine: StatusLineComponent, StatusLine: StatusLineComponent,
Metric: MetricComponent,
Callout: CalloutComponent,
Timeline: TimelineComponent,
TextInput: TextInputComponent, TextInput: TextInputComponent,
Select: SelectComponent, Select: SelectComponent,
MultiSelect: MultiSelectComponent, MultiSelect: MultiSelectComponent,
+17 -6
View File
@@ -101,8 +101,8 @@ export interface PendingConfirmation {
export interface ActionContextValue { export interface ActionContextValue {
/** Registered action handlers */ /** Registered action handlers */
handlers: Record<string, ActionHandler>; handlers: Record<string, ActionHandler>;
/** Actions currently executing (for loading indicators) */ /** Actions currently executing (count of in-flight executions per action name) */
loadingActions: Set<string>; loadingActions: Map<string, number>;
/** Pending confirmation dialog */ /** Pending confirmation dialog */
pendingConfirmation: PendingConfirmation | null; pendingConfirmation: PendingConfirmation | null;
/** Execute an action binding */ /** Execute an action binding */
@@ -141,7 +141,9 @@ export function ActionProvider({
initialHandlersRef.current = initialHandlers; initialHandlersRef.current = initialHandlers;
const navigateRef = useRef(navigate); const navigateRef = useRef(navigate);
navigateRef.current = navigate; navigateRef.current = navigate;
const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set()); const [loadingActions, setLoadingActions] = useState<Map<string, number>>(
new Map(),
);
const [pendingConfirmation, setPendingConfirmation] = const [pendingConfirmation, setPendingConfirmation] =
useState<PendingConfirmation | null>(null); useState<PendingConfirmation | null>(null);
// Ref tracks current pending confirmation so overlapping confirms can // Ref tracks current pending confirmation so overlapping confirms can
@@ -267,7 +269,11 @@ export function ActionProvider({
} }
const actionName = resolved.action; const actionName = resolved.action;
setLoadingActions((prev) => new Set(prev).add(actionName)); setLoadingActions((prev) => {
const next = new Map(prev);
next.set(actionName, (next.get(actionName) ?? 0) + 1);
return next;
});
try { try {
await executeAction({ await executeAction({
action: resolved, action: resolved,
@@ -281,8 +287,13 @@ export function ActionProvider({
}); });
} finally { } finally {
setLoadingActions((prev) => { setLoadingActions((prev) => {
const next = new Set(prev); const next = new Map(prev);
const count = (next.get(actionName) ?? 1) - 1;
if (count <= 0) {
next.delete(actionName); next.delete(actionName);
} else {
next.set(actionName, count);
}
return next; return next;
}); });
} }
@@ -343,7 +354,7 @@ export function useAction(binding: ActionBinding): {
} { } {
const { execute, loadingActions } = useActions(); const { execute, loadingActions } = useActions();
const executeAction = useCallback(() => execute(binding), [execute, binding]); const executeAction = useCallback(() => execute(binding), [execute, binding]);
const isLoading = loadingActions.has(binding.action); const isLoading = (loadingActions.get(binding.action) ?? 0) > 0;
return { execute: executeAction, isLoading }; return { execute: executeAction, isLoading };
} }
+1 -1
View File
@@ -99,7 +99,7 @@ function parsePatchLine(line: string): ParseResult {
// Recovery: strip trailing extra braces/brackets one at a time // Recovery: strip trailing extra braces/brackets one at a time
// LLMs commonly generate extra closing characters in nested JSON // LLMs commonly generate extra closing characters in nested JSON
let attempt = trimmed; let attempt = trimmed;
for (let i = 0; i < 3; i++) { for (let i = 0; i < 8; i++) {
const last = attempt[attempt.length - 1]; const last = attempt[attempt.length - 1];
if (last === "}" || last === "]") { if (last === "}" || last === "]") {
attempt = attempt.slice(0, -1); attempt = attempt.slice(0, -1);
+10
View File
@@ -5,6 +5,7 @@ import React, {
useCallback, useCallback,
useMemo, useMemo,
} from "react"; } from "react";
import { Text } from "ink";
import type { import type {
UIElement, UIElement,
Spec, Spec,
@@ -268,6 +269,9 @@ const ElementRenderer = React.memo(function ElementRenderer({
console.warn( console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`, `[json-render] Missing element "${childKey}" referenced as child of "${resolvedElement.type}". This element will not render.`,
); );
return (
<Text key={childKey} color="red">{`[Missing: ${childKey}]`}</Text>
);
} }
return null; return null;
} }
@@ -346,6 +350,12 @@ function RepeatChildren({
console.warn( console.warn(
`[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`, `[json-render] Missing element "${childKey}" referenced as child of "${element.type}" (repeat). This element will not render.`,
); );
return (
<Text
key={`${key}:${childKey}`}
color="red"
>{`[Missing: ${childKey}]`}</Text>
);
} }
return null; return null;
} }
+14
View File
@@ -1,5 +1,19 @@
# @json-render/jotai # @json-render/jotai
## 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 ## 0.14.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/jotai", "name": "@json-render/jotai",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore", "description": "Jotai adapter for json-render StateStore",
"keywords": [ "keywords": [
+14
View File
@@ -1,5 +1,19 @@
# @json-render/mcp # @json-render/mcp
## 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 ## 0.14.1
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/mcp", "name": "@json-render/mcp",
"version": "0.14.1", "version": "0.16.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.", "description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
"keywords": [ "keywords": [
+17
View File
@@ -0,0 +1,17 @@
# @json-render/next
## 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`.
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/react@0.16.0
+103
View File
@@ -0,0 +1,103 @@
# @json-render/next
Next.js renderer for [@json-render/core](https://json-render.dev). 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
```
## Quick Start
### 1. Define your application spec
```typescript
import type { NextAppSpec } 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: [] } } },
},
"/about": {
layout: "main",
metadata: { title: "About" },
page: { root: "content", elements: { content: { type: "Card", props: { title: "About" }, children: [] } } },
},
},
};
```
### 2. Create the app
```typescript
// lib/app.ts
import { createNextApp } from "@json-render/next/server";
export const { Page, generateMetadata, generateStaticParams } = createNextApp({ spec });
```
### 3. Wire up Next.js routes
```tsx
// app/[[...slug]]/page.tsx
export { Page as default, generateMetadata, generateStaticParams } from "@/lib/app";
```
```tsx
// app/[[...slug]]/layout.tsx
import { NextAppProvider } from "@json-render/next";
import { registry, handlers } from "@/lib/registry";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<NextAppProvider registry={registry} handlers={handlers}>
{children}
</NextAppProvider>
</body>
</html>
);
}
```
## Features
- **Pages as spec** -- Define entire multi-page apps in JSON
- **Route matching** -- Dynamic segments (`[slug]`), catch-all (`[...path]`), optional catch-all (`[[...path]]`)
- **Nested layouts** -- Reusable layouts with `Slot` component for content injection
- **SEO metadata** -- Per-route metadata with title templates, OpenGraph, Twitter cards
- **SSR** -- Server-side rendering via Next.js App Router
- **Data loaders** -- Server-side async data loading before page render
- **Static generation** -- `generateStaticParams` for pre-rendering
- **Client navigation** -- Built-in `Link` component wrapping `next/link`
- **Error/Loading/NotFound** -- Per-route error boundaries, loading states, and 404 pages
- **AI streaming** -- Generate entire apps with JSONL patches via `SpecStream`
## Entry Points
| Import | Description |
| -------------------------- | ------------------------------------------------ |
| `@json-render/next` | Client components (NextAppProvider, PageRenderer) |
| `@json-render/next/server` | Server utilities (createNextApp, matchRoute) |
## Documentation
See the [json-render documentation](https://json-render.dev/docs/api/next) for full API reference.
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@json-render/next",
"version": "0.16.0",
"license": "Apache-2.0",
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
"keywords": [
"json",
"ui",
"next",
"nextjs",
"ai",
"generative-ui",
"llm",
"renderer",
"streaming",
"ssr",
"pages",
"routing"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/next"
},
"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"
},
"./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:*",
"@json-render/react": "workspace:*"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.14",
"next": "16.2.1",
"tsup": "^8.0.2",
"typescript": "^5.4.5"
},
"peerDependencies": {
"next": "^16.2.0",
"react": "^19.2.3"
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Catalog-aware types for @json-render/next.
*
* Re-exports the React catalog types since Next.js uses the same
* component and action model as @json-render/react.
*/
export type {
EventHandle,
BaseComponentProps,
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
ActionFn,
Actions,
} from "@json-render/react";
@@ -0,0 +1,69 @@
"use client";
import React from "react";
import type { Spec } from "@json-render/core";
import { Renderer, type ComponentRegistry } from "@json-render/react";
import { useNextApp } from "./provider";
/**
* Props for NextErrorBoundary.
*/
export interface NextErrorBoundaryProps {
/** The error that occurred */
error: Error & { digest?: string };
/** Function to attempt re-rendering the page */
reset: () => void;
/** Optional error UI spec from the matched route */
errorSpec?: Spec | null;
}
/**
* Error boundary component for json-render Next.js apps.
*
* If the matched route defines an `error` spec, it renders that.
* Otherwise renders a minimal default error UI.
*
* Must be used in a file with `"use client"` directive (Next.js requirement).
*/
export function NextErrorBoundary({
error,
reset,
errorSpec,
}: NextErrorBoundaryProps) {
let registry: ComponentRegistry | undefined;
try {
const ctx = useNextApp();
registry = ctx.registry;
} catch {
// Provider may not be available in error boundaries
}
if (errorSpec && registry) {
const augmentedRegistry: ComponentRegistry = {
...registry,
Slot: () => null,
};
return <Renderer spec={errorSpec} registry={augmentedRegistry} />;
}
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
{error.message || "An unexpected error occurred."}
</p>
<button
onClick={reset}
style={{
padding: "0.5rem 1rem",
borderRadius: "0.375rem",
border: "1px solid #ccc",
background: "#fff",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
"use client";
import React, { type ReactNode } from "react";
import NextLink from "next/link";
import type { ComponentRenderProps } from "@json-render/react";
/**
* Props expected by the Link component in json-render specs.
*/
export interface LinkProps {
/** The target URL or route path */
href: string;
/** Whether to replace the current history entry */
replace?: boolean;
/** Whether to prefetch the linked route */
prefetch?: boolean;
/** CSS class name */
className?: string;
/** Inline styles */
style?: React.CSSProperties;
}
/**
* Built-in Link component for json-render Next.js specs.
*
* Wraps `next/link` for client-side navigation between routes.
*
* Usage in a spec:
* ```json
* {
* "type": "Link",
* "props": { "href": "/about" },
* "children": ["link-text"]
* }
* ```
*/
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
const { href, replace, prefetch, className, style } =
element.props as LinkProps;
return (
<NextLink
href={href}
replace={replace}
prefetch={prefetch}
className={className}
style={style}
>
{children}
</NextLink>
);
}
@@ -0,0 +1,45 @@
import React from "react";
import type { Spec } from "@json-render/core";
/**
* Props for NextLoading.
*/
export interface NextLoadingProps {
/** Optional loading UI spec */
loadingSpec?: Spec | null;
}
/**
* Loading component for json-render Next.js apps.
*
* If a loading spec is provided, it will be rendered by the PageRenderer.
* Otherwise renders a minimal default loading indicator.
*
* Note: Since Next.js loading.tsx is static (no params), this renders
* a generic loading state. Route-specific loading can be achieved with
* Suspense boundaries in page content.
*/
export function NextLoading({ loadingSpec: _loadingSpec }: NextLoadingProps) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "200px",
}}
>
<div
style={{
width: "2rem",
height: "2rem",
border: "2px solid #e5e7eb",
borderTopColor: "#3b82f6",
borderRadius: "50%",
animation: "jr-spin 0.6s linear infinite",
}}
/>
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
</div>
);
}
@@ -0,0 +1,27 @@
import React from "react";
/**
* Not-found component for json-render Next.js apps.
*
* Renders when the pathname does not match any route in the spec.
*/
export function NextNotFound() {
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "400px",
padding: "2rem",
textAlign: "center",
}}
>
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
This page could not be found.
</p>
</div>
);
}
@@ -0,0 +1,135 @@
"use client";
import React, { useMemo, type ReactNode } from "react";
import type { Spec } from "@json-render/core";
import {
Renderer,
StateProvider,
VisibilityProvider,
ValidationProvider,
ActionProvider,
type ComponentRegistry,
type ComponentRenderProps,
} from "@json-render/react";
import { useNextApp } from "./provider";
/**
* Props for PageRenderer.
*/
export interface PageRendererProps {
/** Page element tree spec */
spec: Spec;
/** Initial state (merged from spec.state, global state, and loader data) */
initialState?: Record<string, unknown>;
/** Optional layout element tree spec */
layoutSpec?: Spec | null;
/** Whether the spec is currently loading/streaming */
loading?: boolean;
}
/**
* Built-in Slot component that renders children passed into layouts.
*/
function SlotComponent({ children }: ComponentRenderProps) {
return <>{children}</>;
}
/**
* Client component that renders a json-render page within the Next.js app.
*
* Reads the component registry and action handlers from NextAppProvider context.
* Handles layout rendering by injecting page content into a Slot component.
*/
export function PageRenderer({
spec,
initialState,
layoutSpec,
loading,
}: PageRendererProps) {
const { registry, handlers, navigate } = useNextApp();
const augmentedRegistry: ComponentRegistry = useMemo(() => {
return {
...registry,
Slot: SlotComponent,
};
}, [registry]);
const actionHandlers = useMemo(() => {
const base: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
> = { ...handlers };
base.navigate = (params: Record<string, unknown>) => {
const href = params.href as string | undefined;
if (href) navigate(href);
};
return base;
}, [handlers, navigate]);
const pageContent = (
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
);
if (layoutSpec) {
return (
<StateProvider initialState={initialState}>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers} navigate={navigate}>
<LayoutWithSlot
layoutSpec={layoutSpec}
registry={augmentedRegistry}
loading={loading}
>
{pageContent}
</LayoutWithSlot>
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
return (
<StateProvider initialState={initialState}>
<VisibilityProvider>
<ValidationProvider>
<ActionProvider handlers={actionHandlers} navigate={navigate}>
{pageContent}
</ActionProvider>
</ValidationProvider>
</VisibilityProvider>
</StateProvider>
);
}
/**
* Renders a layout spec and injects children into the Slot.
*/
function LayoutWithSlot({
layoutSpec,
registry,
loading,
children,
}: {
layoutSpec: Spec;
registry: ComponentRegistry;
loading?: boolean;
children: ReactNode;
}) {
const layoutRegistry: ComponentRegistry = useMemo(() => {
return {
...registry,
Slot: function LayoutSlot() {
return <>{children}</>;
},
};
}, [registry, children]);
return (
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
);
}
+92
View File
@@ -0,0 +1,92 @@
"use client";
import React, { createContext, useContext, type ReactNode } from "react";
import { useRouter } from "next/navigation";
import type { ComponentRegistry } from "@json-render/react";
/**
* Context value holding the component registry, action handlers,
* and the Next.js router navigate function.
*/
export interface NextAppContextValue {
registry: ComponentRegistry;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate: (href: string) => void;
}
const NextAppContext = createContext<NextAppContextValue | null>(null);
/**
* Props for NextAppProvider.
*/
export interface NextAppProviderProps {
/** Component registry for rendering */
registry: ComponentRegistry;
/** Action handlers */
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
children: ReactNode;
}
/**
* Provider that holds the component registry and action handlers
* for the entire json-render Next.js application.
*
* Wrap your root layout with this provider:
*
* ```tsx
* // app/[[...slug]]/layout.tsx
* import { NextAppProvider } from "@json-render/next";
* import { registry, handlers } from "@/lib/registry";
*
* export default function Layout({ children }) {
* return (
* <html><body>
* <NextAppProvider registry={registry} handlers={handlers}>
* {children}
* </NextAppProvider>
* </body></html>
* );
* }
* ```
*/
export function NextAppProvider({
registry,
handlers,
children,
}: NextAppProviderProps) {
const router = useRouter();
const navigate = React.useCallback(
(href: string) => router.push(href),
[router],
);
const value = React.useMemo(
() => ({ registry, handlers, navigate }),
[registry, handlers, navigate],
);
return (
<NextAppContext.Provider value={value}>{children}</NextAppContext.Provider>
);
}
/**
* Hook to access the NextApp context.
* Must be used within a NextAppProvider.
*/
export function useNextApp(): NextAppContextValue {
const ctx = useContext(NextAppContext);
if (!ctx) {
throw new Error(
"[json-render/next] useNextApp must be used within a <NextAppProvider>. " +
"Wrap your root layout with <NextAppProvider registry={...} handlers={...}>.",
);
}
return ctx;
}

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