Compare commits

..
Author SHA1 Message Date
Chris Tate 06859dee58 address feedback 2026-01-16 22:05:54 -06:00
Chris Tate 6852a93606 fix lint 2026-01-16 21:32:10 -06:00
Chris Tate 23b5d6b750 fixes 2026-01-16 21:25:02 -06:00
Chris Tate 41d437cce7 codegen 2026-01-16 21:21:34 -06:00
1711 changed files with 8975 additions and 241307 deletions
-8
View File
@@ -1,8 +0,0 @@
{
"mcpServers": {
"json-render": {
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
}
}
}
+24 -64
View File
@@ -13,76 +13,36 @@ concurrency:
cancel-in-progress: true
jobs:
version-sync:
name: Version Sync Check
ci:
name: Lint, Type Check & Build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9.0.0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Check version sync
run: node scripts/check-version-sync.js
node-version: 20
cache: "pnpm"
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- name: Install dependencies
run: pnpm install --frozen-lockfile
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm turbo run build --filter='./packages/*'
- run: pnpm test
- name: Lint
run: pnpm lint
docs:
name: Docs (${{ matrix.environment }})
runs-on: ubuntu-latest
strategy:
matrix:
environment: [production, preview]
env:
VERCEL_ENV: ${{ matrix.environment }}
DOCS_EXPECT_NOINDEX: ${{ matrix.environment == 'preview' && '1' || '0' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter='web^...'
- run: pnpm --filter web build
- run: pnpm --filter web test:routes
- name: Type check
run: pnpm type-check
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm type-check
- name: Test
run: pnpm test
- name: Build
run: pnpm build
-166
View File
@@ -1,166 +0,0 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
jobs:
check-release:
name: Check for new version
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
should_release: ${{ steps.check.outputs.should_release }}
needs_github_release: ${{ steps.check.outputs.needs_github_release }}
version: ${{ steps.check.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
- name: Compare package.json version to npm and check GitHub release
id: check
run: |
LOCAL_VERSION=$(node -p "require('./packages/core/package.json').version")
echo "Local version: $LOCAL_VERSION"
NPM_VERSION=$(npm view @json-render/core version 2>/dev/null || echo "0.0.0")
echo "npm version: $NPM_VERSION"
if [ "$LOCAL_VERSION" != "$NPM_VERSION" ]; then
echo "Version changed: $NPM_VERSION -> $LOCAL_VERSION"
echo "should_release=true" >> "$GITHUB_OUTPUT"
echo "needs_github_release=true" >> "$GITHUB_OUTPUT"
else
echo "Version unchanged on npm, skipping build and publish"
echo "should_release=false" >> "$GITHUB_OUTPUT"
TAG="v$LOCAL_VERSION"
if gh release view "$TAG" &>/dev/null; then
echo "GitHub release $TAG exists"
echo "needs_github_release=false" >> "$GITHUB_OUTPUT"
else
echo "GitHub release $TAG is missing, will create it"
echo "needs_github_release=true" >> "$GITHUB_OUTPUT"
fi
fi
echo "version=$LOCAL_VERSION" >> "$GITHUB_OUTPUT"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish:
name: Publish to npm
needs: check-release
if: needs.check-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: Release
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
cache: pnpm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm run build
- name: Publish all public packages
run: |
LOCAL_VERSION="${{ needs.check-release.outputs.version }}"
FAILED=""
publish_pkg() {
local dir="$1" name="$2"
REGISTRY_VERSION=$(npm view "$name" version 2>/dev/null || echo "0.0.0")
if [ "$LOCAL_VERSION" = "$REGISTRY_VERSION" ]; then
echo "$name@$LOCAL_VERSION already published, skipping"
return 0
fi
echo "Publishing $name@$LOCAL_VERSION..."
TARBALL=$(cd "$dir" && pnpm pack --pack-destination /tmp | tail -1)
if ! npm publish "$TARBALL" --provenance --access public; then
FAILED="$FAILED $name"
fi
}
for dir in packages/*/; do
PKG_NAME=$(node -p "try { const p = require('./$dir/package.json'); p.private ? '' : p.name } catch { '' }")
[ -z "$PKG_NAME" ] && continue
publish_pkg "$dir" "$PKG_NAME"
done
if [ -n "$FAILED" ]; then
echo "Failed to publish:$FAILED"
exit 1
fi
github-release:
name: Create GitHub Release
needs: [check-release, publish]
if: >-
always()
&& needs.check-release.outputs.needs_github_release == 'true'
&& (needs.publish.result == 'success' || needs.publish.result == 'skipped')
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract changelog entry
run: |
VERSION="${{ needs.check-release.outputs.version }}"
awk '/<!-- release:start -->/{found=1; next} /<!-- release:end -->/{found=0} found{print}' CHANGELOG.md > /tmp/release-notes.md
LINES=$(wc -l < /tmp/release-notes.md | tr -d ' ')
if [ "$LINES" -lt 2 ]; then
echo "Error: No release notes found between <!-- release:start --> and <!-- release:end --> markers in CHANGELOG.md"
exit 1
fi
echo "Extracted release notes for $VERSION ($LINES lines)"
- name: Create GitHub Release
run: |
VERSION="${{ needs.check-release.outputs.version }}"
TAG="v$VERSION"
if gh release view "$TAG" &>/dev/null; then
echo "Release $TAG already exists"
else
echo "Creating release $TAG..."
gh release create "$TAG" \
--title "$TAG" \
--target ${{ github.sha }} \
--notes-file /tmp/release-notes.md
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -15
View File
@@ -4,11 +4,13 @@
node_modules
.pnp
.pnp.js
.pnpm-store/
# Local env files
.env*
!.env.example
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
@@ -19,18 +21,11 @@ coverage
# Vercel
.vercel
# Expo
.expo/
# Build Outputs
.next/
out/
build
dist
*.tsbuildinfo
.svelte-kit/
tsup.config.bundled_*.mjs
next-env.d.ts
# Debug
@@ -44,8 +39,3 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
examples/stripe-app/*/.build
examples/stripe-app/*/yarn.lock
-1
View File
@@ -1 +0,0 @@
24
-9
View File
@@ -1,9 +0,0 @@
{
"servers": {
"json-render": {
"type": "stdio",
"command": "npx",
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
}
}
}
-98
View File
@@ -2,111 +2,13 @@
Instructions for AI coding agents working with this codebase.
## Package Management
**Always check the latest version before installing a package.**
Before adding or updating any dependency, verify the current latest version on npm:
```bash
npm view <package-name> version
```
Or check multiple packages at once:
```bash
npm view ai version
npm view @ai-sdk/provider-utils version
npm view zod version
```
This ensures we don't install outdated versions that may have incompatible types or missing features.
## Code Style
- Do not use emojis in code or UI
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
- **Web app docs (`apps/web/`):** Never use Markdown table syntax (`| col | col |`). Always use HTML `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`. Markdown tables do not render correctly in the web app. Inside HTML table cells, curly braces must be escaped as JSX expressions (e.g. `<code>{'{ "$state": "/path" }'}</code>`) because MDX parses `{` as a JSX expression boundary.
## AI SDK / AI Gateway
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
```ts
import { streamText } from "ai";
const result = streamText({
model: "anthropic/claude-haiku-4.5",
prompt: "...",
});
```
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
## Dev Servers
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
Naming convention:
- Main web app: `json-render` → `json-render.localhost:1355`
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
```json
{
"scripts": {
"dev": "portless my-example-demo.json-render next dev --turbopack"
}
}
```
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
- Documentation lives in `apps/web/content/docs/` and uses Geistdocs frontmatter. Keep public `/docs` URLs, heading IDs, `lib/page-titles.ts`, `lib/docs-navigation.ts`, and the content `meta.json` files in sync.
- For docs routing or infrastructure changes, run `pnpm turbo run build --filter='web^...'`, `pnpm --filter web build`, and `pnpm --filter web test:routes`. Existing-page source and Markdown parity are covered by `apps/web/tests/fixtures/docs-baseline.json`; update fixtures only when intentionally changing the documented content.
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
- Package `README.md` files in `packages/*/README.md`
- Root `README.md` (if packages table, install commands, or examples are affected)
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `AGENTS.md` (if workflow or conventions change)
## Releasing
Releases are manual, single-PR affairs. The maintainer controls the changelog voice and format.
All public `@json-render/*` packages share the same version. The canonical version lives in `packages/core/package.json`.
### Preparing a release
When asked to prepare a release (e.g. "prepare v0.17.0"):
1. Create a branch (e.g. `prepare-v0.17.0`)
2. Bump the version in `packages/core/package.json`
3. Run `pnpm run version:sync` to update all other `@json-render/*` packages
4. Write the changelog entry in `CHANGELOG.md`, wrapped in `<!-- release:start -->` and `<!-- release:end -->` markers (move the markers from the previous entry to the new one)
5. **Fill documentation gaps** — every public package should have:
- A row in the root `README.md` packages table
- A renderer section in the root `README.md` (if it's a renderer)
- An API reference page at `apps/web/content/docs/api/<name>.mdx`
- An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts`
- An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`)
- A skill at `skills/<name>/SKILL.md`
- A `packages/<name>/README.md`
6. **Run `pnpm type-check`** after all changes to verify nothing is broken
7. Open a PR and merge to `main`
CI compares the `@json-render/core` version to what's on npm. If it differs, it builds, publishes all public packages, and creates the GitHub release automatically. The release body is extracted from the content between the markers.
### Scripts
- `pnpm run version:sync` — sync all `@json-render/*` package versions to match `@json-render/core`
- `pnpm run version:check` — verify all versions are in sync (runs in CI)
- `pnpm run ci:publish` — build all packages and publish to npm (CI only)
<!-- opensrc:start -->
-110
View File
@@ -1,110 +0,0 @@
# Changelog
## 0.21.0
<!-- release:start -->
### New Features
- **TanStack Start renderer:** Added `@json-render/tanstack-start` for JSON-defined applications with file-based routes, reusable layouts, SSR loaders, head metadata, prerender paths, client navigation, and route fallbacks (#334)
- **Experimental Jev composition:** Added `experimental_composeSpec` and `experimental_createEvaluator` to compose validated specs from app-owned candidates, plus a Jev model option and iterative composition editing in the playground
### Improvements
- **Vue named slots:** Vue registries now support catalog-declared named slots alongside the default `children` slot (#323)
- **React streaming stability:** Stabilized streamed React renders and added coverage for incomplete streamed props and nested prop identities (#325)
- **Documentation and project status:** Expanded renderer, Jev, and package documentation and added Labs status badges to the project README
### Contributors
- @ctate
- @Railly
<!-- release:end -->
## 0.20.0
### New Features
- **Named slots for React:** Components can declare named slots such as `header` and `footer`, while `children` remains the default slot. Slots are preserved through validation, streaming, nested conversion, code export, playground views, and Devtools navigation (#320). Built from the original contribution by @wotnak in #105
- **Nested repeats:** `repeat.statePath` now accepts item-relative paths such as `{ "$item": "employees" }`, enabling nested data rendering across React, React Native, React Email, React PDF, Image, Ink, Solid, Svelte, and Vue (#319). Built from the original contribution by @tmchow in #256
- **Harness chat example:** Added a complete Next.js example using the AI SDK 7 harness adapter, agent delegation, sandbox transport, and json-render components (#302)
### Bug Fixes
- **Chained action params:** Named `onSuccess` and `onError` actions now receive their configured `params` across core and all renderer bridges (#307)
- **Consistent optional visibility:** Element `visible` fields now remain optional across Zod 4 versions, while prompts explicitly require `children` arrays for every element (#299)
- **Spec validation and autofix:** Dangling child references are pruned, malformed visibility conditions are reported, and repeated items can be filtered safely (#300)
### Improvements
- **Release toolchain hardening:** The workspace now requires Node.js 24 and pnpm 11, enforces package engine checks, and applies a minimum package release age (#293)
### Breaking Changes
- Custom renderer bridges that implement the core `executeAction` callback must now accept an `ActionBinding` instead of a bare action name. This exposes chained action params to custom integrations at compile time (#307)
### Contributors
- @ctate
- @Railly
- @tmchow
- @wotnak
## 0.19.0
### New Features
- **Custom directives API** — `@json-render/core` now supports custom directives via `defineDirective`, letting you declare new JSON shapes (like `$format`, `$math`) that resolve to computed values at render time. Directives compose naturally — nest `$format` over `$math` over `$state` and they resolve inside-out. All four renderers (React, Vue, Svelte, Solid) have built-in directive resolution (#279)
- **`@json-render/directives`** — New package shipping seven ready-made directives: `$format` (date, currency, number, percent via `Intl`), `$math` (add, subtract, multiply, divide, mod, min, max, round, floor, ceil, abs), `$concat`, `$count`, `$truncate`, `$pluralize`, and `$join`. Also exports `createI18nDirective` for `$t` translation keys with `{{param}}` interpolation, and `standardDirectives` for one-line registration (#279)
### Improvements
- **Example READMEs** — Added documentation to the chat, dashboard, game-engine, and no-ai examples (#277)
### Contributors
- @ctate
## 0.18.0
### New Features
- **Devtools** — Five new packages for inspecting json-render apps in the browser: `@json-render/devtools` (framework-agnostic core), plus `@json-render/devtools-react`, `@json-render/devtools-vue`, `@json-render/devtools-svelte`, and `@json-render/devtools-solid` adapters. Drop `<JsonRenderDevtools />` into your app to get a shadow-DOM-isolated panel with six tabs (Spec, State, Actions, Stream, Catalog, Pick), a DOM picker that maps clicked elements back to spec keys via `data-jr-key`, a capped event store, and server-side stream tap utilities. Floating toggle or `Cmd`/`Ctrl` + `Shift` + `J`, tree-shakes to `null` in production (#273)
- **Devtools example** — New `examples/devtools` Next.js demo showing the full devtools panel wired up to an AI chat endpoint and a component catalog (#273)
- **Action observer and devtools flag in core** — `@json-render/core` now exposes an action observer and a devtools enablement flag that adapters use to mirror actions and stream events into the panel (#273)
### Bug Fixes
- **Zod 4 schema formatting** — `formatZodType` now correctly handles `z.record()`, `z.default()`, and `z.literal()` types from Zod 4, which previously produced incorrect or empty output in generated prompts and schemas (#239)
### Improvements
- **Zod 4 test coverage** — Added unit tests for `formatZodType` covering record, default, and literal types to guard against regressions (#272)
### Contributors
- @ctate
- @mvanhorn
## 0.17.0
### New Features
- **Gaussian Splatting** — Added `GaussianSplat` component to `@json-render/react-three-fiber`, bringing the component count to 20. Composable with all existing R3F components (lights, controls, post-processing) via drei's Splat loader (#259)
- **Standalone gsplat example** — Experimental demo app showcasing Gaussian Splatting with gsplat.js (no Three.js dependency), featuring scene selector, live JSON spec viewer, and progress indicator (#259)
- **R3F gsplat example** — Demo app with five scenes: splat showroom, splat with primitives, multi-splat, post-processing effects, and animated floating splat (#259)
### Improved
- **AI output quality** — Improved prompt output and schema generation for more reliable AI-generated specs (#268)
### Contributors
- @ctate
- @willmanzoli
## 0.16.0
### Improved
- **Release process** — Switched from Changesets to a manual single-PR release workflow with changelog markers and automatic npm publish on version bump
+130 -739
View File
@@ -1,122 +1,100 @@
# json-render
**The Generative UI framework.**
**Predictable. Guardrailed. Fast.**
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
<p>
<a href="https://vercel.com/labs#labs-products"><img alt="Vercel Labs Product" src="https://img.shields.io/badge/LABS-PRODUCT-0a0a0a.svg?style=for-the-badge&amp;logo=Vercel&amp;labelColor=000000" height="28"></a>
<a href="https://www.npmjs.com/package/@json-render/core"><img alt="npm version: @json-render/core" src="https://img.shields.io/npm/v/%40json-render%2Fcore.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
<a href="https://github.com/vercel-labs/json-render/blob/main/LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/github/license/vercel-labs/json-render.svg?style=for-the-badge&amp;labelColor=000000" height="28"></a>
<a href="https://www.npmjs.com/package/@json-render/core"><img alt="npm downloads per month: @json-render/core" src="https://img.shields.io/npm/dm/%40json-render%2Fcore.svg?style=for-the-badge&amp;labelColor=000000&amp;label=npm%20downloads" height="28"></a>
</p>
Let end users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.
```bash
# for React
npm install @json-render/core @json-render/react
# for React with pre-built shadcn/ui components
npm install @json-render/shadcn
# or for React Native
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for HTML email
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
# or for Vue
npm install @json-render/core @json-render/vue
# or for Svelte
npm install @json-render/core @json-render/svelte
# or for SolidJS
npm install @json-render/core @json-render/solid
# or for terminal UIs
npm install @json-render/core @json-render/ink ink react
# or for full Next.js apps (routes, layouts, SSR, metadata)
npm install @json-render/core @json-render/react @json-render/next
# or for 3D scenes (and gaussian splatting via the GaussianSplat component)
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
```
## Why json-render?
json-render is a **Generative UI** framework: AI generates interfaces from natural language prompts, constrained to components you define. You set the guardrails, AI generates within them:
When users prompt for UI, you need guarantees. json-render gives AI a **constrained vocabulary** so output is always predictable:
- **Guardrailed** - AI can only use components in your catalog
- **Predictable** - JSON output matches your schema, every time
- **Fast** - Stream and render progressively as the model responds
- **Cross-Platform** - React, Vue, Svelte, Solid (web), React Native (mobile) from the same catalog
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
- **Guardrailed** — AI can only use components in your catalog
- **Predictable** — JSON output matches your schema, every time
- **Fast** — Stream and render progressively as the model responds
## Quick Start
### 1. Define Your Catalog
### 1. Define Your Catalog (what AI can use)
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
const catalog = createCatalog({
components: {
Card: {
props: z.object({ title: z.string() }),
description: "A card container",
hasChildren: true,
},
Metric: {
props: z.object({
label: z.string(),
value: z.string(),
format: z.enum(["currency", "percent", "number"]).nullable(),
valuePath: z.string(), // Binds to your data
format: z.enum(['currency', 'percent', 'number']),
}),
description: "Display a metric value",
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
action: ActionSchema, // AI declares intent, you handle it
}),
description: "Clickable button",
},
},
actions: {
export_report: { description: "Export dashboard to PDF" },
refresh_data: { description: "Refresh all metrics" },
export_report: { description: 'Export dashboard to PDF' },
refresh_data: { description: 'Refresh all metrics' },
},
});
```
### 2. Define Your Components
### 2. Register Your Components (how they render)
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
),
const registry = {
Card: ({ element, children }) => (
<div className="card">
<h3>{element.props.title}</h3>
{children}
</div>
),
Metric: ({ element }) => {
const value = useDataValue(element.props.valuePath);
return <div className="metric">{format(value)}</div>;
},
});
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action)}>
{element.props.label}
</button>
),
};
```
### 3. Render AI-Generated Specs
### 3. Let AI Generate
```tsx
function Dashboard({ spec }) {
return <Renderer spec={spec} registry={registry} />;
import { DataProvider, ActionProvider, Renderer, useUIStream } from '@json-render/react';
function Dashboard() {
const { tree, send } = useUIStream({ api: '/api/generate' });
return (
<DataProvider initialData={{ revenue: 125000, growth: 0.15 }}>
<ActionProvider actions={{
export_report: () => downloadPDF(),
refresh_data: () => refetch(),
}}>
<input
placeholder="Create a revenue dashboard..."
onKeyDown={(e) => e.key === 'Enter' && send(e.target.value)}
/>
<Renderer tree={tree} components={registry} />
</ActionProvider>
</DataProvider>
);
}
```
@@ -124,676 +102,85 @@ function Dashboard({ spec }) {
---
## Packages
| Package | Description |
| --------------------------- | ---------------------------------------------------------------------- |
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/vue` | Vue 3 renderer, composables, providers |
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@json-render/shadcn-svelte`| 36 pre-built shadcn-svelte components (Svelte 5 + Tailwind CSS) |
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (20 built-in components, including GaussianSplat) |
| `@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/tanstack-start` | TanStack Start renderer — full apps with routes, layouts, SSR, and head metadata |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
| `@json-render/ink` | Ink terminal renderer with built-in components for interactive TUIs. |
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
| `@json-render/directives` | Pre-built custom directives — $format, $math, $concat, $count, $truncate, $pluralize, $join, $t (i18n) |
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
| `@json-render/devtools` | Framework-agnostic devtools core — panel UI, event store, picker, stream taps |
| `@json-render/devtools-react` | React adapter for `@json-render/devtools` (drop-in `<JsonRenderDevtools />`) |
| `@json-render/devtools-vue` | Vue adapter for `@json-render/devtools` |
| `@json-render/devtools-svelte` | Svelte adapter for `@json-render/devtools` |
| `@json-render/devtools-solid` | SolidJS adapter for `@json-render/devtools` |
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform |
## Renderers
### React (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
// Flat spec format (root key + elements map)
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Hello" },
children: ["button-1"],
},
"button-1": {
type: "Button",
props: { label: "Click me" },
children: [],
},
},
};
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />;
```
### Vue (UI)
```typescript
import { h } from "vue";
import { defineRegistry, Renderer } from "@json-render/vue";
import { schema } from "@json-render/vue/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) =>
h("div", { class: "card" }, [h("h3", null, props.title), children]),
Button: ({ props, emit }) =>
h("button", { onClick: () => emit("press") }, props.label),
},
});
// In your Vue component template:
// <Renderer :spec="spec" :registry="registry" />
```
### Svelte (UI)
```typescript
import { defineRegistry, Renderer } from "@json-render/svelte";
import { schema } from "@json-render/svelte/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => /* Svelte 5 snippet */,
Button: ({ props, emit }) => /* Svelte 5 snippet */,
},
});
// In your Svelte component:
// <Renderer spec={spec} registry={registry} />
```
### Solid (UI)
```tsx
import { defineRegistry, Renderer } from "@json-render/solid";
import { schema } from "@json-render/solid/schema";
const { registry } = defineRegistry(catalog, {
components: {
Card: (renderProps) => <div>{renderProps.children}</div>,
Button: (renderProps) => (
<button onClick={() => renderProps.emit("press")}>
{renderProps.element.props.label as string}
</button>
),
},
});
<Renderer spec={spec} registry={registry} />;
```
### shadcn/ui (Web)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { defineRegistry, Renderer } from "@json-render/react";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { shadcnComponents } from "@json-render/shadcn";
// Pick components from the 36 standard definitions
const catalog = defineCatalog(schema, {
components: {
Card: shadcnComponentDefinitions.Card,
Stack: shadcnComponentDefinitions.Stack,
Heading: shadcnComponentDefinitions.Heading,
Button: shadcnComponentDefinitions.Button,
},
actions: {},
});
// Use matching implementations
const { registry } = defineRegistry(catalog, {
components: {
Card: shadcnComponents.Card,
Stack: shadcnComponents.Stack,
Heading: shadcnComponents.Heading,
Button: shadcnComponents.Button,
},
});
<Renderer spec={spec} registry={registry} />;
```
### React Native (Mobile)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-native/schema";
import {
standardComponentDefinitions,
standardActionDefinitions,
} from "@json-render/react-native/catalog";
import { defineRegistry, Renderer } from "@json-render/react-native";
// 25+ standard components included
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
<Renderer spec={spec} registry={registry} />;
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
clips: [
{
id: "clip-1",
trackId: "main",
component: "TitleCard",
props: { title: "Hello" },
from: 0,
durationInFrames: 90,
},
],
audio: { tracks: [] },
};
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>;
```
### React PDF (Documents)
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: {
type: "Document",
props: { title: "Invoice" },
children: ["page-1"],
},
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
```
### React Email (Email)
```typescript
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
const spec = {
root: "html-1",
elements: {
"html-1": {
type: "Html",
props: { lang: "en", dir: "ltr" },
children: ["head-1", "body-1"],
},
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
props: { style: { backgroundColor: "#f6f9fc" } },
children: ["container-1"],
},
"container-1": {
type: "Container",
props: {
style: { maxWidth: "600px", margin: "0 auto", padding: "20px" },
},
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": {
type: "Text",
props: { text: "Thanks for signing up." },
children: [],
},
},
};
const html = await renderToHtml(spec);
```
### Image (SVG/PNG)
```typescript
import { renderToPng } from "@json-render/image/render";
const spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
// Render to PNG (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
// Or render to SVG string
import { renderToSvg } from "@json-render/image/render";
const svg = await renderToSvg(spec, { fonts });
```
### Three.js (3D)
```tsx
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import {
threeComponentDefinitions,
threeComponents,
ThreeCanvas,
} from "@json-render/react-three-fiber";
const catalog = defineCatalog(schema, {
components: {
Box: threeComponentDefinitions.Box,
Sphere: threeComponentDefinitions.Sphere,
AmbientLight: threeComponentDefinitions.AmbientLight,
DirectionalLight: threeComponentDefinitions.DirectionalLight,
GaussianSplat: threeComponentDefinitions.GaussianSplat,
OrbitControls: threeComponentDefinitions.OrbitControls,
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Box: threeComponents.Box,
Sphere: threeComponents.Sphere,
AmbientLight: threeComponents.AmbientLight,
DirectionalLight: threeComponents.DirectionalLight,
GaussianSplat: threeComponents.GaussianSplat,
OrbitControls: threeComponents.OrbitControls,
},
});
<ThreeCanvas
spec={spec}
registry={registry}
shadows
camera={{ position: [5, 5, 5], fov: 50 }}
style={{ width: "100%", height: "100vh" }}
/>;
```
### 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>
```
### TanStack Start (Full Apps)
```tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import {
PageRenderer,
StartErrorBoundary,
StartLoading,
StartNotFound,
type StartAppSpec,
} from "@json-render/tanstack-start";
import { createStartApp } from "@json-render/tanstack-start/server";
const spec: StartAppSpec = {
metadata: { title: { default: "My App", template: "%s | My App" } },
routes: {
"/": {
metadata: { title: "Home" },
page: {
root: "hero",
elements: {
hero: { type: "Card", props: { title: "Welcome" }, children: [] },
},
},
},
},
};
const { getPageData, getHead } = createStartApp({ spec });
export const Route = createFileRoute("/$")({
loader: async ({ location }) => {
const data = await getPageData({ pathname: location.pathname });
if (!data) throw notFound();
return data;
},
head: ({ match }) => getHead({ pathname: match.pathname }),
component: () => <PageRenderer {...Route.useLoaderData()} />,
pendingComponent: StartLoading,
errorComponent: StartErrorBoundary,
notFoundComponent: StartNotFound,
});
```
Wrap the root route's outlet with `<StartAppProvider spec={spec}>` so route
fallback components can resolve the current route. Pass named `$computed`
implementations through its `functions` prop.
### 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} />
```
### Devtools
Drop-in inspector panel for any json-render app. Spec tree, state editor, action log, stream log, catalog browser, DOM picker.
```tsx
// React
import { JsonRenderDevtools } from "@json-render/devtools-react";
<JSONUIProvider registry={registry} handlers={handlers}>
<Renderer spec={spec} registry={registry} />
<JsonRenderDevtools spec={spec} catalog={catalog} messages={messages} />
</JSONUIProvider>;
```
Floating toggle appears bottom-right. Hotkey: `Ctrl`/`Cmd` + `Shift` + `J`. Tree-shakes to `null` in production.
Available for React, Vue, Svelte, and Solid — swap `@json-render/devtools-react` for the adapter that matches your renderer.
### Ink (Terminal)
```tsx
import { defineCatalog } from "@json-render/core";
import {
schema,
standardComponentDefinitions,
standardActionDefinitions,
defineRegistry,
Renderer,
JSONUIProvider,
} from "@json-render/ink";
const catalog = defineCatalog(schema, {
components: { ...standardComponentDefinitions },
actions: standardActionDefinitions,
});
const { registry } = defineRegistry(catalog, { components: {} });
const spec = {
root: "card-1",
elements: {
"card-1": {
type: "Card",
props: { title: "Status" },
children: ["status-1"],
},
"status-1": {
type: "StatusLine",
props: { label: "Build", status: "success" },
children: [],
},
},
};
<JSONUIProvider initialState={{}}>
<Renderer spec={spec} registry={registry} />
</JSONUIProvider>;
```
## Features
### Streaming (SpecStream)
Stream AI responses progressively:
```typescript
import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process chunks as they arrive
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result
// Get final result
const finalSpec = compiler.getResult();
```
### AI Prompt Generation
Generate system prompts from your catalog:
```typescript
const systemPrompt = catalog.prompt();
// Includes component descriptions, props schemas, available actions
```
### Conditional Visibility
Show/hide components based on data, auth, or complex logic:
```json
{
"type": "Alert",
"props": { "message": "Error occurred" },
"visible": [
{ "$state": "/form/hasError" },
{ "$state": "/form/errorDismissed", "not": true }
]
"visible": {
"and": [
{ "path": "/form/hasError" },
{ "not": { "path": "/form/errorDismissed" } }
]
}
}
```
### Dynamic Props
```json
{
"type": "AdminPanel",
"visible": { "auth": "signedIn" }
}
```
Any prop value can be data-driven using expressions:
### Rich Actions
Actions with confirmation dialogs and callbacks:
```json
{
"type": "Icon",
"type": "Button",
"props": {
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
"label": "Refund Payment",
"action": {
"name": "refund",
"params": {
"paymentId": { "path": "/selected/id" },
"amount": { "path": "/refund/amount" }
},
"confirm": {
"title": "Confirm Refund",
"message": "Refund ${/refund/amount} to customer?",
"variant": "danger"
},
"onSuccess": { "set": { "/ui/success": true } },
"onError": { "set": { "/ui/error": "$error.message" } }
}
}
}
```
Expression forms:
- **`{ "$state": "/state/key" }`** - reads a value from the state model
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition and picks a branch
- **`{ "$template": "Hello, ${/user/name}!" }`** - interpolates state values into strings
- **`{ "$computed": "fn", "args": { ... } }`** - calls a registered function with resolved args
### Actions
Components can trigger actions, including the built-in `setState` action:
### Built-in Validation
```json
{
"type": "Pressable",
"type": "TextField",
"props": {
"action": "setState",
"actionParams": { "statePath": "/activeTab", "value": "home" }
},
"children": ["home-icon"]
}
```
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
### State Watchers
React to state changes by triggering actions:
```json
{
"type": "Select",
"props": {
"value": { "$bindState": "/form/country" },
"options": ["US", "Canada", "UK"]
},
"watch": {
"/form/country": {
"action": "loadCities",
"params": { "country": { "$state": "/form/country" } }
}
"label": "Email",
"valuePath": "/form/email",
"checks": [
{ "fn": "required", "message": "Email is required" },
{ "fn": "email", "message": "Invalid email" }
],
"validateOn": "blur"
}
}
```
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
---
## Packages
| Package | Description |
|---------|-------------|
| `@json-render/core` | Types, schemas, visibility, actions, validation |
| `@json-render/react` | React renderer, providers, hooks |
## Demo
```bash
@@ -803,36 +190,40 @@ pnpm install
pnpm dev
```
- http://json-render.localhost:1355 - Docs & Playground
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- [Experimental Jev composition](https://json-render.dev/docs/jev): use `experimental_composeSpec` and `experimental_createEvaluator` from core with your own catalog, or select **Jev (Experimental)** in `/playground`. Unreleased; source-build instructions are in the guide.
- Svelte Example: run `pnpm dev` in `examples/svelte` or `examples/svelte-chat`
- Vue Example: run `pnpm dev` in `examples/vue`
- Vite Renderers (React + Vue + Svelte + Solid): run `pnpm dev` in `examples/vite-renderers`
- React Native example: run `npx expo start` in `examples/react-native`
- Gaussian Splatting (R3F): run `pnpm dev` in `examples/react-three-fiber-gsplat`
- Gaussian Splatting (experimental standalone gsplat.js demo): run `pnpm dev` in `examples/gsplat`
- http://localhost:3000 — Docs & Playground
- http://localhost:3001 — Example Dashboard
## Project Structure
```
json-render/
├── packages/
│ ├── core/ → @json-render/core
│ └── react/ → @json-render/react
├── apps/
│ └── web/ → Docs & Playground site
└── examples/
└── dashboard/ → Example dashboard app
```
## How It Works
```mermaid
flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([streamed])
```
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
│ User Prompt │────▶│ AI + Catalog│────▶│ JSON Tree │
│ "dashboard" │ │ (guardrailed)│ │(predictable)│
└─────────────┘ └──────────────┘ └─────────────┘
│
┌──────────────┐ │
│ Your React │◀───────────┘
│ Components │ (streamed)
└──────────────┘
```
1. **Define the guardrails** - what components, actions, and data bindings AI can use
2. **Prompt** - describe what you want in natural language
3. **AI generates JSON** - output is always predictable, constrained to your catalog
4. **Render fast** - stream and render progressively as the model responds
1. **Define the guardrails** — what components, actions, and data bindings AI can use
2. **Users prompt** — end users describe what they want in natural language
3. **AI generates JSON** — output is always predictable, constrained to your catalog
4. **Render fast** — stream and render progressively as the model responds
## License
+2 -20
View File
@@ -1,22 +1,4 @@
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# AI Gateway API Key (required for /api/generate endpoint)
# Uses Vercel AI Gateway - automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# Dedicated AI Gateway key for the experimental Jev playground option
# Required locally and on Vercel; no fallback to AI_GATEWAY_API_KEY
JEV_AI_GATEWAY_API_KEY=
# AI Model Configuration
# Override the default model used for UI generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Vercel KV (Rate Limiting)
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
# Rate Limiting
# RATE_LIMIT_PER_MINUTE=10
# RATE_LIMIT_PER_DAY=100
-2
View File
@@ -11,7 +11,6 @@
# next.js
/.next/
/.source/
/out/
# production
@@ -36,4 +35,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.env*.local
-104
View File
@@ -1,104 +0,0 @@
# web
## 0.1.11
### Patch Changes
- Updated dependencies [519a538]
- @json-render/core@0.16.0
- @json-render/codegen@0.16.0
- @json-render/react@0.16.0
- @json-render/yaml@0.16.0
## 0.1.10
### Patch Changes
- Updated dependencies [bf3a7ec]
- @json-render/core@0.15.0
- @json-render/codegen@0.15.0
- @json-render/react@0.15.0
- @json-render/yaml@0.15.0
## 0.1.9
### Patch Changes
- Updated dependencies [43b7515]
- @json-render/core@0.14.1
- @json-render/codegen@0.14.1
- @json-render/react@0.14.1
- @json-render/yaml@0.14.1
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/yaml@0.14.0
- @json-render/codegen@0.14.0
- @json-render/react@0.14.0
## 0.1.7
### Patch Changes
- Updated dependencies [5b32de8]
- @json-render/core@0.13.0
- @json-render/codegen@0.13.0
- @json-render/react@0.13.0
## 0.1.6
### Patch Changes
- Updated dependencies [54a1ecf]
- @json-render/core@0.12.1
- @json-render/codegen@0.12.1
- @json-render/react@0.12.1
## 0.1.5
### Patch Changes
- Updated dependencies [63c339b]
- @json-render/core@0.12.0
- @json-render/codegen@0.12.0
- @json-render/react@0.12.0
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/codegen@0.11.0
- @json-render/react@0.11.0
## 0.1.3
### Patch Changes
- Updated dependencies [9cef4e9]
- @json-render/core@0.10.0
- @json-render/react@0.10.0
- @json-render/codegen@0.10.0
## 0.1.2
### Patch Changes
- Updated dependencies [b103676]
- @json-render/react@0.9.1
- @json-render/core@0.9.1
- @json-render/codegen@0.9.1
## 0.1.1
### Patch Changes
- Updated dependencies [1d755c1]
- @json-render/core@0.9.0
- @json-render/react@0.9.0
- @json-render/codegen@0.9.0
+1 -5
View File
@@ -14,11 +14,7 @@ pnpm dev
bun dev
```
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
## Jev composition experiment
The **default / jev** toggle in `/playground` includes an experimental Jev option; hover or focus its info icon segment for details. It is a reference consumer of core's reusable `experimental_composeSpec` and `experimental_createEvaluator` APIs. It lets Jev compose and edit UI trees from the playground's component catalog and allowed action bindings through Vercel AI Gateway. Set `JEV_AI_GATEWAY_API_KEY` on the server for Jev; the default model uses `AI_GATEWAY_API_KEY`. Follow-ups use the selected version as `initialSpec` and can add, replace, remove, or move elements; earlier versions remain unchanged. It uses the same prompt input, version history, spec/stream inspectors, and functional preview as the default model. The shared `/api/generate` endpoint streams spec patches and decision metadata. See [setup, architecture, and limits](lib/jev/README.md).
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
-8
View File
@@ -1,8 +0,0 @@
import type { ReactNode } from "react";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("examples");
export default function ExamplesLayout({ children }: { children: ReactNode }) {
return children;
}
-133
View File
@@ -1,133 +0,0 @@
"use client";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { examples, allTags, getGitHubUrl, type Example } from "@/lib/examples";
import { cn } from "@/lib/utils";
function ExampleCard({ example }: { example: Example }) {
return (
<div className="group flex flex-col rounded-xl border border-border bg-card text-card-foreground overflow-hidden transition-colors hover:border-foreground/25">
<div className="flex flex-1 flex-col gap-3 p-5">
<h3 className="font-semibold leading-none">{example.title}</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
{example.description}
</p>
<div className="flex flex-wrap gap-1.5">
{example.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[11px]">
{tag}
</Badge>
))}
</div>
<div className="mt-auto flex items-center gap-3 pt-2">
{example.demoUrl && (
<a
href={example.demoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-foreground hover:text-primary transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
</svg>
Live Demo
</a>
)}
<a
href={getGitHubUrl(example)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg
viewBox="0 0 16 16"
className="h-3.5 w-3.5"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
Source
</a>
</div>
</div>
</div>
);
}
export default function ExamplesPage() {
const [activeTag, setActiveTag] = useState<string | null>(null);
const filtered = activeTag
? examples.filter((e) => e.tags.includes(activeTag))
: examples;
return (
<section className="mx-auto max-w-6xl px-6 py-16">
<div className="mb-10">
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl">
Examples
</h1>
<p className="mt-3 text-lg text-muted-foreground">
Explore json-render across frameworks, renderers, and use cases.
</p>
</div>
<div className="mb-8 flex flex-wrap gap-2">
<button
onClick={() => setActiveTag(null)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
activeTag === null
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
)}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag}
onClick={() => setActiveTag(activeTag === tag ? null : tag)}
className={cn(
"rounded-full border px-3 py-1 text-xs font-medium transition-colors",
activeTag === tag
? "border-foreground bg-foreground text-background"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/50",
)}
>
{tag}
</button>
))}
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((example) => (
<ExampleCard key={example.slug} example={example} />
))}
</div>
{filtered.length === 0 && (
<p className="py-12 text-center text-muted-foreground">
No examples match the selected filter.
</p>
)}
</section>
);
}
-7
View File
@@ -1,7 +0,0 @@
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return <main className="min-h-[calc(100dvh-4rem)]">{children}</main>;
}
@@ -1,43 +0,0 @@
import { MobileDocsBar } from "@vercel/geistdocs/mobile-docs-bar";
import { createDocsPage } from "@vercel/geistdocs/pages/docs";
import { notFound } from "next/navigation";
import { GenerationModesDiagram } from "@/components/generation-modes-diagram";
import { PackageInstall } from "@/components/package-install";
import { isSafePathSegments } from "@/lib/docs-source";
import { config } from "@/lib/geistdocs/config";
import { geistdocsSource } from "@/lib/geistdocs/source";
import { pageMetadata } from "@/lib/page-metadata";
type PageProps = { params: Promise<{ lang: string; slug?: string[] }> };
async function validate(params: PageProps["params"]) {
const resolved = await params;
if (resolved.lang !== "en" || !isSafePathSegments(resolved.slug ?? []))
notFound();
try {
if (!geistdocsSource.source.getPage(resolved.slug, resolved.lang))
notFound();
} catch (error) {
if (error instanceof URIError) notFound();
throw error;
}
return resolved;
}
const docsPage = createDocsPage({
config,
source: geistdocsSource,
mdx: { GenerationModesDiagram, PackageInstall },
renderTop: ({ data }) => <MobileDocsBar toc={data.toc} />,
});
export default async function Page({ params }: PageProps) {
return <docsPage.Page params={Promise.resolve(await validate(params))} />;
}
export async function generateMetadata({ params }: PageProps) {
const { slug = [] } = await validate(params);
return pageMetadata(["docs", ...slug].join("/"));
}
export const generateStaticParams = docsPage.generateStaticParams;
-25
View File
@@ -1,25 +0,0 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { GeistdocsDocsLayout } from "@vercel/geistdocs/layout";
import { config } from "@/lib/geistdocs/config";
import { geistdocsSource } from "@/lib/geistdocs/source";
export default async function DocsLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
if (lang !== "en") notFound();
return (
<GeistdocsDocsLayout
config={config}
tree={geistdocsSource.source.getPageTree(lang)}
containerProps={{ className: "mx-auto max-w-[1448px]" }}
>
{children}
</GeistdocsDocsLayout>
);
}
-106
View File
@@ -1,106 +0,0 @@
import { convertToModelMessages, stepCountIs, streamText } from "ai";
import type { ModelMessage, UIMessage } from "ai";
import { createBashTool } from "bash-tool";
import { headers } from "next/headers";
import { loadAllDocsSources } from "@/lib/docs-source";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
export const maxDuration = 60;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render, a library for AI-generated UI with guardrails.
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/tanstack-start, @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/directives, @json-render/codegen, @json-render/devtools, @json-render/devtools-react, @json-render/devtools-vue, @json-render/devtools-svelte, @json-render/devtools-solid, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, tanstack-start, ink, react-pdf, react-email, react-native, shadcn, shadcn-svelte, react-three-fiber, image, remotion, vue, svelte, solid, directives, codegen, devtools, devtools-react, devtools-vue, devtools-svelte, devtools-solid, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
Experimental Jev composition: core exports experimental_composeSpec and experimental_createEvaluator for app-owned catalogs/candidates through Vercel AI Gateway. See /docs/jev for availability, source-build setup, and limits; do not assume the currently published npm version includes it.
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.
When answering questions:
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
- Do NOT use bash to write, create, modify, or delete files (no tee, cat >, sed -i, echo >, cp, mv, rm, mkdir, touch, etc.) — you are read-only
- Always base your answers on the actual documentation content
- Be concise and accurate
- If the docs don't cover a topic, say so honestly
- Do NOT include source references or file paths in your response
- Do NOT use emojis in your responses`;
async function loadDocsFiles(): Promise<Record<string, string>> {
const pages = await loadAllDocsSources();
return Object.fromEntries(
pages.map((page) => [
page.href === "/docs" ? "/docs/index.md" : `${page.href}.md`,
page.markdown,
]),
);
}
function addCacheControl(messages: ModelMessage[]): ModelMessage[] {
if (messages.length === 0) return messages;
return messages.map((message, index) => {
if (index === messages.length - 1) {
return {
...message,
providerOptions: {
...message.providerOptions,
anthropic: { cacheControl: { type: "ephemeral" } },
},
};
}
return message;
});
}
export async function POST(req: Request) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { messages }: { messages: UIMessage[] } = await req.json();
const docsFiles = await loadDocsFiles();
const {
tools: { bash, readFile },
} = await createBashTool({ files: docsFiles });
const result = streamText({
model: DEFAULT_MODEL,
system: SYSTEM_PROMPT,
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools: {
bash,
readFile,
},
prepareStep: ({ messages: stepMessages }) => ({
messages: addCacheControl(stepMessages),
}),
});
return result.toUIMessageStreamResponse();
}
-25
View File
@@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { loadDocsSource } from "@/lib/docs-source";
export async function GET(req: NextRequest) {
const docPath = req.nextUrl.searchParams.get("path");
if (!docPath)
return NextResponse.json(
{ error: "Missing ?path= parameter" },
{ status: 400 },
);
const path = docPath.startsWith("/") ? docPath : `/${docPath}`;
if (!/^\/docs(?:\/[a-zA-Z0-9_-]+)*\/?$/.test(path)) {
return NextResponse.json({ error: "Invalid path" }, { status: 400 });
}
const page = await loadDocsSource(path);
if (!page)
return NextResponse.json({ error: "Page not found" }, { status: 404 });
return new NextResponse(page.markdown, {
headers: {
"Content-Type": "text/markdown; charset=utf-8",
"Cache-Control": "public, max-age=3600",
Link: `<${page.canonicalUrl}>; rel="canonical"`,
},
});
}
@@ -1,21 +0,0 @@
import { loadDocsSource, isSafePathSegments } from "@/lib/docs-source";
import { applyDocsResponseHeaders } from "@/lib/docs-response-headers";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug?: string[] }> },
) {
const { slug = [] } = await params;
const path = `/docs${slug.length ? `/${slug.join("/")}` : ""}`;
const page = isSafePathSegments(slug) ? await loadDocsSource(path) : null;
const headers = new Headers({
"Content-Type": "text/markdown; charset=utf-8",
});
applyDocsResponseHeaders(headers);
if (page) headers.set("Link", `<${page.canonicalUrl}>; rel="canonical"`);
return new Response(
page?.markdown ??
"# Page Not Found\n\nSee [the documentation index](/llms.txt).\n",
{ status: page ? 200 : 404, headers },
);
}
+79 -140
View File
@@ -1,156 +1,95 @@
import { streamText } from "ai";
import { headers } from "next/headers";
import type { Spec, EditMode } from "@json-render/core";
import {
buildUserPrompt,
buildEditUserPrompt,
isNonEmptySpec,
} from "@json-render/core";
import { yamlPrompt } from "@json-render/yaml";
import { stringify as yamlStringify } from "yaml";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/render/catalog";
import { createCompositionResponse } from "@/lib/jev/response";
export const maxDuration = 60;
export const maxDuration = 30;
const PLAYGROUND_RULES = [
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts. Keep the total UI compact — avoid sprawling multi-section pages. Prefer a single focused Card over a full page layout.",
"Wrap each repeated item in a Card for visual separation and structure.",
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
"NEVER use emoji characters. Use the Icon component with Lucide icon names instead. For example, use Icon with name:'MapPin' instead of a pin emoji, Icon with name:'Mail' instead of an envelope emoji, etc.",
"For icon+label patterns, use a horizontal Stack with gap:'sm' and align:'center' containing an Icon and a Text.",
"For any tabular or list data with consistent columns (items, orders, stats), ALWAYS use the Table component. Never simulate tables with Stacks — the columns won't align.",
];
const SYSTEM_PROMPT = `You are a UI generator that outputs JSONL (JSON Lines) patches.
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
AVAILABLE COMPONENTS (22):
function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
if (isYaml) {
return yamlPrompt(playgroundCatalog, {
mode: "standalone",
customRules: PLAYGROUND_RULES,
editModes: editModes ?? ["merge"],
});
}
return playgroundCatalog.prompt({
customRules: PLAYGROUND_RULES,
editModes,
});
}
Layout:
- Card: { title?: string, description?: string, maxWidth?: "sm"|"md"|"lg"|"full", centered?: boolean } - Container card for content sections. Has children. Use for forms/content boxes, NOT for page headers.
- Stack: { direction?: "horizontal"|"vertical", gap?: "sm"|"md"|"lg" } - Flex container. Has children.
- Grid: { columns?: 2|3|4, gap?: "sm"|"md"|"lg" } - Grid layout. Has children. ALWAYS use mobile-first: set columns:1 and use className for larger screens.
- Divider: {} - Horizontal separator line
function buildYamlUserPrompt(
prompt: string,
previousSpec?: Spec | null,
editModes?: EditMode[],
): string {
if (isNonEmptySpec(previousSpec)) {
return buildEditUserPrompt({
prompt,
currentSpec: previousSpec,
config: { modes: editModes ?? ["merge"] },
format: "yaml",
maxPromptLength: MAX_PROMPT_LENGTH,
serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
});
}
Form Inputs:
- Input: { label: string, name: string, type?: "text"|"email"|"password"|"number", placeholder?: string } - Text input
- Textarea: { label: string, name: string, placeholder?: string, rows?: number } - Multi-line text
- Select: { label: string, name: string, options: string[], placeholder?: string } - Dropdown select
- Checkbox: { label: string, name: string, checked?: boolean } - Checkbox input
- Radio: { label: string, name: string, options: string[] } - Radio button group
- Switch: { label: string, name: string, checked?: boolean } - Toggle switch
const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
return [
userText,
"",
"Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
].join("\n");
}
Actions:
- Button: { label: string, variant?: "primary"|"secondary"|"danger", actionText?: string } - Clickable button. actionText is shown in toast on click (defaults to label)
- Link: { label: string, href: string } - Anchor link
Typography:
- Heading: { text: string, level?: 1|2|3|4 } - Heading text (h1-h4)
- Text: { content: string, variant?: "body"|"caption"|"muted" } - Paragraph text
Data Display:
- Image: { src: string, alt: string, width?: number, height?: number } - Image
- Avatar: { src?: string, name: string, size?: "sm"|"md"|"lg" } - User avatar with fallback initials
- Badge: { text: string, variant?: "default"|"success"|"warning"|"danger" } - Status badge
- Alert: { title: string, message?: string, type?: "info"|"success"|"warning"|"error" } - Alert banner
- Progress: { value: number, max?: number, label?: string } - Progress bar (value 0-100)
- Rating: { value: number, max?: number, label?: string } - Star rating display
Charts:
- BarGraph: { title?: string, data: Array<{label: string, value: number}> } - Vertical bar chart
- LineGraph: { title?: string, data: Array<{label: string, value: number}> } - Line chart with points
OUTPUT FORMAT (JSONL):
{"op":"set","path":"/root","value":"element-key"}
{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}
ALL COMPONENTS support: className?: string[] - array of Tailwind classes for custom styling
RULES:
1. First line sets /root to root element key
2. Add elements with /elements/{key}
3. Children array contains string keys, not objects
4. Parent first, then children
5. Each element needs: key, type, props
6. Use className for custom Tailwind styling when needed
FORBIDDEN CLASSES (NEVER USE):
- min-h-screen, h-screen, min-h-full, h-full, min-h-dvh, h-dvh - viewport heights break the small render container
- bg-gray-50, bg-slate-50 or any page background colors - container already has background
MOBILE-FIRST RESPONSIVE:
- ALWAYS design mobile-first. Single column on mobile, expand on larger screens.
- Grid: Use columns:1 prop, add className:["sm:grid-cols-2"] or ["md:grid-cols-3"] for larger screens
- DO NOT put page headers/titles inside Card - use Stack with Heading directly
- Horizontal stacks that may overflow should use className:["flex-wrap"]
- For forms (login, signup, contact): Card should be the root element, NOT wrapped in a centering Stack
EXAMPLE (Blog with responsive grid):
{"op":"set","path":"/root","value":"page"}
{"op":"add","path":"/elements/page","value":{"key":"page","type":"Stack","props":{"direction":"vertical","gap":"lg"},"children":["header","posts"]}}
{"op":"add","path":"/elements/header","value":{"key":"header","type":"Stack","props":{"direction":"vertical","gap":"sm"},"children":["title","desc"]}}
{"op":"add","path":"/elements/title","value":{"key":"title","type":"Heading","props":{"text":"My Blog","level":1}}}
{"op":"add","path":"/elements/desc","value":{"key":"desc","type":"Text","props":{"content":"Latest posts","variant":"muted"}}}
{"op":"add","path":"/elements/posts","value":{"key":"posts","type":"Grid","props":{"columns":1,"gap":"md","className":["sm:grid-cols-2","lg:grid-cols-3"]},"children":["post1"]}}
{"op":"add","path":"/elements/post1","value":{"key":"post1","type":"Card","props":{"title":"Post Title"},"children":["excerpt"]}}
{"op":"add","path":"/elements/excerpt","value":{"key":"excerpt","type":"Text","props":{"content":"Post content...","variant":"body"}}}
Generate JSONL:`;
const MAX_PROMPT_LENGTH = 140;
export async function POST(req: Request) {
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
const { prompt } = await req.json();
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
]);
if (!minuteResult.success || !dailyResult.success) {
const isMinuteLimit = !minuteResult.success;
return new Response(
JSON.stringify({
error: "Rate limit exceeded",
message: isMinuteLimit
? "Too many requests. Please wait a moment before trying again."
: "Daily limit reached. Please try again tomorrow.",
}),
{
status: 429,
headers: { "Content-Type": "application/json" },
},
);
}
const { prompt, context, format, editModes, model } = await req.json();
if (model === "typesafe-ai/jev")
return createCompositionResponse(req, prompt, context?.previousSpec);
const isYaml = format === "yaml";
const systemPrompt = getSystemPrompt(isYaml, editModes);
const userPrompt = isYaml
? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
: buildUserPrompt({
prompt,
currentSpec: context?.previousSpec,
maxPromptLength: MAX_PROMPT_LENGTH,
editModes,
});
const sanitizedPrompt = String(prompt || "").slice(0, MAX_PROMPT_LENGTH);
const result = streamText({
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
abortSignal: req.signal,
system: [
{
role: "system",
content: systemPrompt,
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
],
prompt: userPrompt,
model: "anthropic/claude-opus-4.5",
system: SYSTEM_PROMPT,
prompt: sanitizedPrompt,
temperature: 0.7,
});
const encoder = new TextEncoder();
const textStream = result.textStream;
const stream = new ReadableStream({
async start(controller) {
for await (const chunk of textStream) {
controller.enqueue(encoder.encode(chunk));
}
try {
const usage = await result.usage;
const meta = JSON.stringify({
__meta: "usage",
promptTokens: usage.inputTokens,
completionTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
});
controller.enqueue(encoder.encode(`\n${meta}\n`));
} catch {
// Usage not available
}
controller.close();
},
});
return new Response(stream, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
return result.toTextStreamResponse();
}
-80
View File
@@ -1,80 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getSearchIndex } from "@/lib/search-index";
import { createSearchRoute } from "@vercel/geistdocs/routes/search";
import { geistdocsSource } from "@/lib/geistdocs/source";
import { config } from "@/lib/geistdocs/config";
const docsSearch = createSearchRoute({ config, source: geistdocsSource });
export async function GET(req: NextRequest) {
if (req.nextUrl.searchParams.has("query")) return docsSearch(req);
const q = req.nextUrl.searchParams.get("q")?.trim().toLowerCase();
if (!q) {
return NextResponse.json({ results: [] });
}
const index = await getSearchIndex();
const terms = q.split(/\s+/).filter(Boolean);
const results = index
.map((entry) => {
const titleLower = entry.title.toLowerCase();
const contentLower = entry.content.toLowerCase();
const titleMatch = terms.every((t) => titleLower.includes(t));
const contentMatch = terms.every((t) => contentLower.includes(t));
if (!titleMatch && !contentMatch) return null;
let snippet = "";
if (contentMatch) {
const firstTermIdx = Math.min(
...terms.map((t) => {
const idx = contentLower.indexOf(t);
return idx === -1 ? Infinity : idx;
}),
);
if (firstTermIdx !== Infinity) {
const start = Math.max(0, firstTermIdx - 40);
const end = Math.min(entry.content.length, firstTermIdx + 120);
snippet =
(start > 0 ? "..." : "") +
entry.content.slice(start, end).replace(/\n/g, " ") +
(end < entry.content.length ? "..." : "");
}
}
return {
title: entry.title,
href: entry.href,
section: entry.section,
snippet,
score: titleMatch ? 2 : 1,
};
})
.filter(
(
r,
): r is {
title: string;
href: string;
section: string;
snippet: string;
score: number;
} => r !== null,
)
.sort((a, b) => b.score - a.score)
.slice(0, 20)
.map(({ title, href, section, snippet }) => ({
title,
href,
section,
snippet,
}));
return NextResponse.json(
{ results },
{ headers: { "Cache-Control": "public, max-age=60" } },
);
}
+163
View File
@@ -0,0 +1,163 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Actions | json-render",
};
export default function ActionsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Actions</h1>
<p className="text-muted-foreground mb-8">
Handle user interactions safely with named actions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Why Named Actions?</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
name. Your application provides the implementation. This is a core
guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Defining Actions</h2>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
components: { /* ... */ },
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().optional(),
}).optional(),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">ActionProvider</h2>
<p className="text-sm text-muted-foreground mb-4">
Provide action handlers to your app:
</p>
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
function App() {
const handlers = {
submit_form: async (params) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
return response.json();
},
export_data: async (params) => {
const blob = await generateExport(params.format, params.filters);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
};
return (
<ActionProvider handlers={handlers}>
{/* Your UI */}
</ActionProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using Actions in Components
</h2>
<Code lang="tsx">{`const Button = ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
);
// Or use the useAction hook
import { useAction } from '@json-render/react';
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Actions with Confirmation
</h2>
<p className="text-sm text-muted-foreground mb-4">
AI can declare actions that require user confirmation:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Delete Account",
"action": {
"name": "delete_account",
"params": { "userId": "123" },
"confirm": {
"title": "Delete Account?",
"message": "This action cannot be undone.",
"variant": "danger"
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Action Callbacks</h2>
<p className="text-sm text-muted-foreground mb-4">
Handle success and error states:
</p>
<Code lang="json">{`{
"type": "Button",
"props": {
"label": "Save",
"action": {
"name": "save_changes",
"params": { "documentId": "doc-1" },
"onSuccess": {
"set": { "/ui/savedMessage": "Changes saved!" }
},
"onError": {
"set": { "/ui/errorMessage": "$error.message" }
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/visibility"
className="text-foreground hover:underline"
>
conditional visibility
</Link>
.
</p>
</article>
);
}
+117
View File
@@ -0,0 +1,117 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "AI SDK Integration | json-render",
};
export default function AiSdkPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">AI SDK Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render with the Vercel AI SDK for seamless streaming.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Installation</h2>
<Code lang="bash">npm install ai</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">API Route Setup</h2>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
// Optionally include current UI state for context
const contextPrompt = currentTree
? \`\\n\\nCurrent UI state:\\n\${JSON.stringify(currentTree, null, 2)}\`
: '';
const result = streamText({
model: 'anthropic/claude-opus-4.5',
system: systemPrompt + contextPrompt,
prompt,
});
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Client-Side Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">useUIStream</code> on the client:
</p>
<Code lang="tsx">{`'use client';
import { useUIStream } from '@json-render/react';
function GenerativeUI() {
const { tree, isLoading, error, generate } = useUIStream({
endpoint: '/api/generate',
});
return (
<div>
<button
onClick={() => generate('Create a dashboard with metrics')}
disabled={isLoading}
>
{isLoading ? 'Generating...' : 'Generate'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer tree={tree} registry={registry} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Prompt Engineering</h2>
<p className="text-sm text-muted-foreground mb-4">
The <code className="text-foreground">generateCatalogPrompt</code>{" "}
function creates an optimized prompt that:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Lists all available components and their props</li>
<li>Describes available actions</li>
<li>Specifies the expected JSON output format</li>
<li>Includes examples for better generation</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Custom System Prompts
</h2>
<Code lang="typescript">{`const basePrompt = generateCatalogPrompt(catalog);
const customPrompt = \`
\${basePrompt}
Additional instructions:
- Always use Card components for grouping related content
- Prefer horizontal layouts (Row) for metrics
- Use consistent spacing with padding="md"
\`;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/streaming"
className="text-foreground hover:underline"
>
progressive streaming
</Link>
.
</p>
</article>
);
}
+130
View File
@@ -0,0 +1,130 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/codegen API | json-render",
};
export default function CodegenApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/codegen</h1>
<p className="text-muted-foreground mb-8">
Utilities for generating code from UI trees.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Tree Traversal</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
<p className="text-sm text-muted-foreground mb-4">
Walk the UI tree depth-first.
</p>
<Code lang="typescript">{`function traverseTree(
tree: UITree,
visitor: TreeVisitor,
startKey?: string
): void
interface TreeVisitor {
(element: UIElement, depth: number, parent: UIElement | null): void;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all unique component types used in a tree.
</p>
<Code lang="typescript">{`function collectUsedComponents(tree: UITree): Set<string>
// Example
const components = collectUsedComponents(tree);
// Set { 'Card', 'Metric', 'Chart' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectDataPaths</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all data paths referenced in props (valuePath, dataPath, bindPath,
etc.).
</p>
<Code lang="typescript">{`function collectDataPaths(tree: UITree): Set<string>
// Example
const paths = collectDataPaths(tree);
// Set { 'analytics/revenue', 'analytics/customers' }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectActions</h3>
<p className="text-sm text-muted-foreground mb-4">
Get all action names used in the tree.
</p>
<Code lang="typescript">{`function collectActions(tree: UITree): Set<string>
// Example
const actions = collectActions(tree);
// Set { 'submit_form', 'refresh_data' }`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Serialization</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">serializePropValue</h3>
<p className="text-sm text-muted-foreground mb-4">
Serialize a single value to a code string.
</p>
<Code lang="typescript">{`function serializePropValue(
value: unknown,
options?: SerializeOptions
): { value: string; needsBraces: boolean }
// Examples
serializePropValue("hello")
// { value: '"hello"', needsBraces: false }
serializePropValue(42)
// { value: '42', needsBraces: true }
serializePropValue({ path: 'user/name' })
// { value: '{ path: "user/name" }', needsBraces: true }`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
<p className="text-sm text-muted-foreground mb-4">
Serialize a props object to a JSX attributes string.
</p>
<Code lang="typescript">{`function serializeProps(
props: Record<string, unknown>,
options?: SerializeOptions
): string
// Example
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
// 'title="Dashboard" columns={3} disabled'`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">escapeString</h3>
<p className="text-sm text-muted-foreground mb-4">
Escape a string for use in code.
</p>
<Code lang="typescript">{`function escapeString(
str: string,
quotes?: 'single' | 'double'
): string`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">GeneratedFile</h3>
<Code lang="typescript">{`interface GeneratedFile {
/** File path relative to project root */
path: string;
/** File contents */
content: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">CodeGenerator</h3>
<Code lang="typescript">{`interface CodeGenerator {
/** Generate files from a UI tree */
generate(tree: UITree): GeneratedFile[];
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SerializeOptions</h3>
<Code lang="typescript">{`interface SerializeOptions {
/** Quote style for strings */
quotes?: 'single' | 'double';
/** Indent for objects/arrays */
indent?: number;
}`}</Code>
</article>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/core API | json-render",
};
export default function CoreApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/core</h1>
<p className="text-muted-foreground mb-8">
Core types, schemas, and utilities.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">createCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a catalog definition.
</p>
<Code lang="typescript">{`function createCatalog(config: CatalogConfig): Catalog
interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
validationFunctions?: Record<string, ValidationFunctionDef>;
}
interface ComponentDefinition {
props: ZodObject;
hasChildren?: boolean;
description?: string;
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
generateCatalogPrompt
</h2>
<p className="text-sm text-muted-foreground mb-4">
Generates a system prompt for AI models.
</p>
<Code lang="typescript">{`function generateCatalogPrompt(catalog: Catalog): string`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">evaluateVisibility</h2>
<p className="text-sm text-muted-foreground mb-4">
Evaluates a visibility condition against data and auth state.
</p>
<Code lang="typescript">{`function evaluateVisibility(
condition: VisibilityCondition | undefined,
data: Record<string, unknown>,
auth?: AuthState
): boolean
type VisibilityCondition =
| { path: string }
| { auth: 'signedIn' | 'signedOut' | string }
| { and: VisibilityCondition[] }
| { or: VisibilityCondition[] }
| { not: VisibilityCondition }
| { eq: [DynamicValue, DynamicValue] }
| { gt: [DynamicValue, DynamicValue] }
| { gte: [DynamicValue, DynamicValue] }
| { lt: [DynamicValue, DynamicValue] }
| { lte: [DynamicValue, DynamicValue] };`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">UIElement</h3>
<Code lang="typescript">{`interface UIElement {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">UITree</h3>
<Code lang="typescript">{`interface UITree {
root: UIElement | null;
elements: Record<string, UIElement>;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action</h3>
<Code lang="typescript">{`interface Action {
name: string;
params?: Record<string, unknown>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationSchema</h3>
<Code lang="typescript">{`interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
fn: string;
args?: Record<string, unknown>;
message: string;
}`}</Code>
</article>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/react API | json-render",
};
export default function ReactApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/react</h1>
<p className="text-muted-foreground mb-8">
React components, providers, and hooks.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Providers</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">DataProvider</h3>
<Code lang="tsx">{`<DataProvider initialData={object}>
{children}
</DataProvider>`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ActionProvider</h3>
<Code lang="tsx">{`<ActionProvider handlers={Record<string, ActionHandler>}>
{children}
</ActionProvider>
type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">VisibilityProvider</h3>
<Code lang="tsx">{`<VisibilityProvider auth={AuthState}>
{children}
</VisibilityProvider>
interface AuthState {
isSignedIn: boolean;
roles?: string[];
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ValidationProvider</h3>
<Code lang="tsx">{`<ValidationProvider functions={Record<string, ValidatorFn>}>
{children}
</ValidationProvider>
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
tree={UITree}
registry={ComponentRegistry}
/>
type ComponentRegistry = Record<string, React.ComponentType<ComponentProps>>;
interface ComponentProps {
element: UIElement;
children?: React.ReactNode;
onAction: (name: string, params: object) => void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const {
tree, // UITree - current UI state
isLoading, // boolean - true while streaming
error, // Error | null
generate, // (prompt: string) => void
abort, // () => void
} = useUIStream({
endpoint: string,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
<Code lang="typescript">{`const {
data, // Record<string, unknown>
setData, // (data: object) => void
getValue, // (path: string) => unknown
setValue, // (path: string, value: unknown) => void
} = useData();`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useDataValue</h3>
<Code lang="typescript">{`const value = useDataValue(path: string);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useDataBinding</h3>
<Code lang="typescript">{`const [value, setValue] = useDataBinding(path: string);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useActions</h3>
<Code lang="typescript">{`const { dispatch } = useActions();
// dispatch(actionName: string, params: object)`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useAction</h3>
<Code lang="typescript">{`const submitForm = useAction('submit_form');
// submitForm(params: object)`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useIsVisible</h3>
<Code lang="typescript">{`const isVisible = useIsVisible(condition?: VisibilityCondition);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useFieldValidation</h3>
<Code lang="typescript">{`const {
value, // unknown
setValue, // (value: unknown) => void
errors, // string[]
validate, // () => Promise<boolean>
isValid, // boolean
} = useFieldValidation(path: string, checks: ValidationCheck[]);`}</Code>
</article>
);
}
+120
View File
@@ -0,0 +1,120 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Catalog | json-render",
};
export default function CatalogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Catalog</h1>
<p className="text-muted-foreground mb-8">
The catalog defines what AI can generate. It&apos;s your guardrail.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Catalog?</h2>
<p className="text-sm text-muted-foreground mb-4">
A catalog is a schema that defines:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Components</strong> — UI elements
AI can create
</li>
<li>
<strong className="text-foreground">Actions</strong> — Operations AI
can trigger
</li>
<li>
<strong className="text-foreground">Validation Functions</strong> —
Custom validators for form inputs
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
const catalog = createCatalog({
components: {
// Define each component with its props schema
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
padding: z.enum(['sm', 'md', 'lg']).default('md'),
}),
hasChildren: true, // Can contain other components
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // JSON Pointer to data
format: z.enum(['currency', 'percent', 'number']),
}),
},
},
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
},
},
validationFunctions: {
isValidEmail: {
description: 'Validates email format',
},
isPhoneNumber: {
description: 'Validates phone number',
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Definition</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the catalog has:
</p>
<Code lang="typescript">{`{
props: z.object({...}), // Zod schema for props
hasChildren?: boolean, // Can it have children?
description?: string, // Help AI understand when to use it
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Generating AI Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">generateCatalogPrompt</code> to
create a system prompt for AI:
</p>
<Code lang="typescript">{`import { generateCatalogPrompt } from '@json-render/core';
const systemPrompt = generateCatalogPrompt(catalog);
// Pass this to your AI model as the system prompt`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn how to{" "}
<Link
href="/docs/components"
className="text-foreground hover:underline"
>
register React components
</Link>{" "}
for your catalog.
</p>
</article>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Code Export | json-render",
};
export default function CodeExportPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Code Export</h1>
<p className="text-muted-foreground mb-8">
Export generated UI as standalone code for your framework.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
While json-render is designed for dynamic rendering, you can export
generated UI as static code. The code generation is intentionally
project-specific so you have full control over:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
<li>Component templates (standalone, no json-render dependencies)</li>
<li>Package.json and project structure</li>
<li>Framework-specific patterns (Next.js, Remix, etc.)</li>
<li>How data is passed to components</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
<p className="text-sm text-muted-foreground mb-4">
Code export is split into two parts:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
1. @json-render/codegen (utilities)
</h3>
<p className="text-sm text-muted-foreground mb-4">
Framework-agnostic utilities for building code generators:
</p>
<Code lang="typescript">{`import {
traverseTree, // Walk the UI tree
collectUsedComponents, // Get all component types used
collectDataPaths, // Get all data binding paths
collectActions, // Get all action names
serializeProps, // Convert props to JSX string
} from '@json-render/codegen';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
2. Your Project (generator)
</h3>
<p className="text-sm text-muted-foreground mb-4">
Custom code generator specific to your project and framework:
</p>
<Code lang="typescript">{`// lib/codegen/generator.ts
import { collectUsedComponents, serializeProps } from '@json-render/codegen';
export function generateNextJSProject(tree: UITree): GeneratedFile[] {
const components = collectUsedComponents(tree);
return [
{ path: 'package.json', content: '...' },
{ path: 'app/page.tsx', content: '...' },
// ... component files
];
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Example: Next.js Export
</h2>
<p className="text-sm text-muted-foreground mb-4">
See the dashboard example for a complete implementation that exports:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li>
<code className="text-foreground">package.json</code> - Dependencies
and scripts
</li>
<li>
<code className="text-foreground">tsconfig.json</code> - TypeScript
config
</li>
<li>
<code className="text-foreground">next.config.js</code> - Next.js
config
</li>
<li>
<code className="text-foreground">app/layout.tsx</code> - Root layout
</li>
<li>
<code className="text-foreground">app/globals.css</code> - Global
styles
</li>
<li>
<code className="text-foreground">app/page.tsx</code> - Generated page
with data
</li>
<li>
<code className="text-foreground">components/ui/*.tsx</code> -
Standalone components
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Standalone Components
</h2>
<p className="text-sm text-muted-foreground mb-4">
The exported components are standalone with no json-render dependencies.
They receive data as props instead of using hooks:
</p>
<Code lang="tsx">{`// Generated component (standalone)
interface MetricProps {
label: string;
valuePath: string;
data?: Record<string, unknown>;
}
export function Metric({ label, valuePath, data }: MetricProps) {
const value = data ? getByPath(data, valuePath) : undefined;
return (
<div>
<span>{label}</span>
<span>{formatValue(value)}</span>
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Utilities</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">traverseTree</h3>
<Code lang="typescript">{`import { traverseTree } from '@json-render/codegen';
traverseTree(tree, (element, depth, parent) => {
console.log(' '.repeat(depth * 2) + element.type);
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">collectUsedComponents</h3>
<Code lang="typescript">{`import { collectUsedComponents } from '@json-render/codegen';
const components = collectUsedComponents(tree);
// Set { 'Card', 'Metric', 'Chart', 'Table' }
// Generate only the needed component files
for (const component of components) {
files.push({
path: \`components/ui/\${component.toLowerCase()}.tsx\`,
content: componentTemplates[component],
});
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">serializeProps</h3>
<Code lang="typescript">{`import { serializeProps } from '@json-render/codegen';
const propsStr = serializeProps({
title: 'Dashboard',
columns: 3,
disabled: true,
});
// 'title="Dashboard" columns={3} disabled'`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Try It</h2>
<p className="text-sm text-muted-foreground mb-4">
Run the dashboard example and click &quot;Export Project&quot; to see
code generation in action:
</p>
<Code lang="bash">{`cd examples/dashboard
pnpm dev
# Open http://localhost:3001
# Generate a widget, then click "Export Project"`}</Code>
</article>
);
}
+111
View File
@@ -0,0 +1,111 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Components | json-render",
};
export default function ComponentsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Components</h1>
<p className="text-muted-foreground mb-8">
Register React components to render your catalog types.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a registry that maps catalog component types to React components:
</p>
<Code lang="tsx">{`const registry = {
Card: ({ element, children }) => (
<div className="card">
<h2>{element.props.title}</h2>
{element.props.description && (
<p>{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button onClick={() => onAction(element.props.action, {})}>
{element.props.label}
</button>
),
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component receives these props:
</p>
<Code lang="typescript">{`interface ComponentProps {
element: {
key: string;
type: string;
props: Record<string, unknown>;
children?: UIElement[];
visible?: VisibilityCondition;
validation?: ValidationSchema;
};
children?: React.ReactNode; // Rendered children
onAction: (name: string, params: object) => void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks to read and write data:
</p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
const Metric = ({ element }) => {
// Read-only value
const value = useDataValue(element.props.valuePath);
return (
<div className="metric">
<span className="label">{element.props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
};
const TextField = ({ element }) => {
// Two-way binding
const [value, setValue] = useDataBinding(element.props.valuePath);
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder={element.props.placeholder}
/>
);
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function App() {
return (
<Renderer
tree={uiTree}
registry={registry}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values.
</p>
</article>
);
}
+133
View File
@@ -0,0 +1,133 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Data Binding | json-render",
};
export default function DataBindingPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Data Binding</h1>
<p className="text-muted-foreground mb-8">
Connect UI components to your application data using JSON Pointer paths.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">JSON Pointer Paths</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses JSON Pointer (RFC 6901) for data paths:
</p>
<Code lang="json">{`// Given this data:
{
"user": {
"name": "Alice",
"email": "alice@example.com"
},
"metrics": {
"revenue": 125000,
"growth": 0.15
}
}
// These paths access:
"/user/name" -> "Alice"
"/metrics/revenue" -> 125000
"/metrics/growth" -> 0.15`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">DataProvider</h2>
<p className="text-sm text-muted-foreground mb-4">
Wrap your app with DataProvider to enable data binding:
</p>
<Code lang="tsx">{`import { DataProvider } from '@json-render/react';
function App() {
const initialData = {
user: { name: 'Alice' },
form: { email: '', message: '' },
};
return (
<DataProvider initialData={initialData}>
{/* Your UI */}
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Reading Data</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">useDataValue</code> for read-only
access:
</p>
<Code lang="tsx">{`import { useDataValue } from '@json-render/react';
function UserGreeting() {
const name = useDataValue('/user/name');
return <h1>Hello, {name}!</h1>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Two-Way Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">useDataBinding</code> for
read-write access:
</p>
<Code lang="tsx">{`import { useDataBinding } from '@json-render/react';
function EmailInput() {
const [email, setEmail] = useDataBinding('/form/email');
return (
<input
type="email"
value={email || ''}
onChange={(e) => setEmail(e.target.value)}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using the Data Context
</h2>
<p className="text-sm text-muted-foreground mb-4">
Access the full data context for advanced use cases:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
function DataDebugger() {
const { data, setData, getValue, setValue } = useData();
// Read any path
const revenue = getValue('/metrics/revenue');
// Write any path
const updateRevenue = () => setValue('/metrics/revenue', 150000);
// Replace all data
const resetData = () => setData({ user: {}, form: {} });
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">In JSON UI Trees</h2>
<p className="text-sm text-muted-foreground mb-4">
AI can reference data paths in component props:
</p>
<Code lang="json">{`{
"type": "Metric",
"props": {
"label": "Total Revenue",
"valuePath": "/metrics/revenue",
"format": "currency"
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/actions" className="text-foreground hover:underline">
actions
</Link>{" "}
for user interactions.
</p>
</article>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { PackageInstall } from "@/components/package-install";
export const metadata = {
title: "Installation | json-render",
};
export default function InstallationPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Installation</h1>
<p className="text-muted-foreground mb-8">
Install the core and React packages to get started.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Install packages</h2>
<PackageInstall packages="@json-render/core @json-render/react" />
<h2 className="text-xl font-semibold mt-12 mb-4">Peer Dependencies</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render requires the following peer dependencies:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">react</code> ^19.0.0
</li>
<li>
<code className="text-foreground">zod</code> ^4.0.0
</li>
</ul>
<PackageInstall packages="react zod" />
<h2 className="text-xl font-semibold mt-12 mb-4">For AI Integration</h2>
<p className="text-sm text-muted-foreground mb-4">
To use json-render with AI models, you&apos;ll also need the Vercel AI
SDK:
</p>
<PackageInstall packages="ai" />
</article>
);
}
+81
View File
@@ -0,0 +1,81 @@
import Link from "next/link";
import { DocsMobileNav } from "@/components/docs-mobile-nav";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Code Export", href: "/docs/code-export" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
],
},
];
export default function DocsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<DocsMobileNav />
<div className="max-w-5xl mx-auto px-6 py-8 lg:py-12 flex gap-16">
{/* Sidebar */}
<aside className="w-48 shrink-0 hidden lg:block">
<nav className="sticky top-20 space-y-6">
{navigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="text-sm text-muted-foreground hover:text-foreground transition-colors block py-1"
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
</nav>
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
</div>
</>
);
}
+67
View File
@@ -0,0 +1,67 @@
export const metadata = {
title: "Introduction | json-render",
};
export default function DocsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Introduction</h1>
<p className="text-muted-foreground mb-8">
Predictable. Guardrailed. Fast. Let users generate dashboards, widgets,
apps, and data visualizations from prompts.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is json-render?</h2>
<p className="text-sm text-muted-foreground mb-4 leading-relaxed">
json-render lets end users generate UI from natural language prompts —
safely constrained to components you define. You set the guardrails:
what components exist, what props they take, what actions are available.
AI generates JSON that matches your schema, and your components render
it natively.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Why json-render?</h2>
<div className="space-y-4 mb-8">
<div>
<h3 className="font-medium mb-1">Guardrailed</h3>
<p className="text-sm text-muted-foreground">
AI can only use components in your catalog. No arbitrary code
generation.
</p>
</div>
<div>
<h3 className="font-medium mb-1">Predictable</h3>
<p className="text-sm text-muted-foreground">
JSON output matches your schema, every time. Actions are declared by
name, you control what they do.
</p>
</div>
<div>
<h3 className="font-medium mb-1">Fast</h3>
<p className="text-sm text-muted-foreground">
Stream and render progressively as the model responds. No waiting
for completion.
</p>
</div>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">How it works</h2>
<ol className="list-decimal list-inside space-y-2 text-sm text-muted-foreground">
<li>
Define the guardrails — what components, actions, and data bindings AI
can use
</li>
<li>
Users prompt — end users describe what they want in natural language
</li>
<li>
AI generates JSON — output is always predictable, constrained to your
catalog
</li>
<li>
Render fast — stream and render progressively as the model responds
</li>
</ol>
</article>
);
}
+205
View File
@@ -0,0 +1,205 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Quick Start | json-render",
};
export default function QuickStartPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Quick Start</h1>
<p className="text-muted-foreground mb-8">
Get up and running with json-render in 5 minutes.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
1. Define your catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog that defines what components AI can use:
</p>
<Code lang="typescript">{`// lib/catalog.ts
import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const catalog = createCatalog({
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
hasChildren: true,
},
Button: {
props: z.object({
label: z.string(),
action: z.string(),
}),
},
Text: {
props: z.object({
content: z.string(),
}),
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
},
navigate: {
params: z.object({ url: z.string() }),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Register React components that render each catalog type:
</p>
<Code lang="tsx">{`// components/registry.tsx
export const registry = {
Card: ({ element, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{element.props.title}</h2>
{element.props.description && (
<p className="text-gray-600">{element.props.description}</p>
)}
{children}
</div>
),
Button: ({ element, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction(element.props.action, {})}
>
{element.props.label}
</button>
),
Text: ({ element }) => (
<p>{element.props.content}</p>
),
};`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Create an API route
</h2>
<p className="text-sm text-muted-foreground mb-4">
Set up a streaming API route for AI generation:
</p>
<Code lang="typescript">{`// app/api/generate/route.ts
import { streamText } from 'ai';
import { generateCatalogPrompt } from '@json-render/core';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const systemPrompt = generateCatalogPrompt(catalog);
const result = streamText({
model: 'anthropic/claude-opus-4.5',
system: systemPrompt,
prompt,
});
return new Response(result.textStream, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { registry } from '@/components/registry';
export default function Page() {
const { tree, isLoading, generate } = useUIStream({
endpoint: '/api/generate',
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
generate(formData.get('prompt') as string);
};
return (
<DataProvider initialData={{}}>
<VisibilityProvider>
<ActionProvider handlers={{
submit: (params) => console.log('Submit:', params),
navigate: (params) => console.log('Navigate:', params),
}}>
<form onSubmit={handleSubmit}>
<input
name="prompt"
placeholder="Describe what you want..."
className="border p-2 rounded"
/>
<button type="submit" disabled={isLoading}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer tree={tree} registry={registry} />
</div>
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next steps</h2>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2">
<li>
Learn about{" "}
<Link
href="/docs/catalog"
className="text-foreground hover:underline"
>
catalogs
</Link>{" "}
in depth
</li>
<li>
Explore{" "}
<Link
href="/docs/data-binding"
className="text-foreground hover:underline"
>
data binding
</Link>{" "}
for dynamic values
</li>
<li>
Add{" "}
<Link
href="/docs/actions"
className="text-foreground hover:underline"
>
actions
</Link>{" "}
for interactivity
</li>
<li>
Implement{" "}
<Link
href="/docs/visibility"
className="text-foreground hover:underline"
>
conditional visibility
</Link>
</li>
</ul>
</article>
);
}
+133
View File
@@ -0,0 +1,133 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Streaming | json-render",
};
export default function StreamingPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Streaming</h1>
<p className="text-muted-foreground mb-8">
Progressively render UI as AI generates it.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">How Streaming Works</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses JSONL (JSON Lines) streaming. As AI generates, each
line represents a patch operation:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"add","path":"/root/children","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
The hook handles parsing and state management:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function App() {
const {
tree, // Current UI tree state
isLoading, // True while streaming
error, // Any error that occurred
generate, // Function to start generation
abort, // Function to cancel streaming
} = useUIStream({
endpoint: '/api/generate',
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
<p className="text-sm text-muted-foreground mb-4">
Supported operations:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li>
<code className="text-foreground">set</code> — Set the value at a path
(creates if needed)
</li>
<li>
<code className="text-foreground">add</code> — Add to an array at a
path
</li>
<li>
<code className="text-foreground">replace</code> — Replace value at a
path
</li>
<li>
<code className="text-foreground">remove</code> — Remove value at a
path
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Path Format</h2>
<p className="text-sm text-muted-foreground mb-4">
Paths use a key-based format for elements:
</p>
<Code lang="bash">{`/root -> Root element
/root/children -> Children of root
/elements/card-1 -> Element with key "card-1"
/elements/card-1/children -> Children of card-1`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Server-Side Setup</h2>
<p className="text-sm text-muted-foreground mb-4">
Ensure your API route streams properly:
</p>
<Code lang="typescript">{`export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-opus-4.5',
system: generateCatalogPrompt(catalog),
prompt,
});
// Return as a streaming response
return new Response(result.textStream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
'Cache-Control': 'no-cache',
},
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Progressive Rendering
</h2>
<p className="text-sm text-muted-foreground mb-4">
The Renderer automatically updates as the tree changes:
</p>
<Code lang="tsx">{`function App() {
const { tree, isLoading } = useUIStream({ endpoint: '/api/generate' });
return (
<div>
{isLoading && <LoadingIndicator />}
<Renderer tree={tree} registry={registry} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<Code lang="tsx">{`function App() {
const { isLoading, generate, abort } = useUIStream({
endpoint: '/api/generate',
});
return (
<div>
<button onClick={() => generate('Create dashboard')}>
Generate
</button>
{isLoading && (
<button onClick={abort}>Cancel</button>
)}
</div>
);
}`}</Code>
</article>
);
}
+185
View File
@@ -0,0 +1,185 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Validation | json-render",
};
export default function ValidationPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Validation</h1>
<p className="text-muted-foreground mb-8">
Validate form inputs with built-in and custom functions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Validators</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render includes common validation functions:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">required</code> — Value must be
non-empty
</li>
<li>
<code className="text-foreground">email</code> — Valid email format
</li>
<li>
<code className="text-foreground">minLength</code> — Minimum string
length
</li>
<li>
<code className="text-foreground">maxLength</code> — Maximum string
length
</li>
<li>
<code className="text-foreground">pattern</code> — Match a regex
pattern
</li>
<li>
<code className="text-foreground">min</code> — Minimum numeric value
</li>
<li>
<code className="text-foreground">max</code> — Maximum numeric value
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Using Validation in JSON
</h2>
<Code lang="json">{`{
"type": "TextField",
"props": {
"label": "Email",
"valuePath": "/form/email",
"checks": [
{ "fn": "required", "message": "Email is required" },
{ "fn": "email", "message": "Invalid email format" }
],
"validateOn": "blur"
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Validation with Parameters
</h2>
<Code lang="json">{`{
"type": "TextField",
"props": {
"label": "Password",
"valuePath": "/form/password",
"checks": [
{ "fn": "required", "message": "Password is required" },
{
"fn": "minLength",
"args": { "length": 8 },
"message": "Password must be at least 8 characters"
},
{
"fn": "pattern",
"args": { "pattern": "[A-Z]" },
"message": "Must contain at least one uppercase letter"
}
]
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Custom Validation Functions
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define custom validators in your catalog:
</p>
<Code lang="typescript">{`const catalog = createCatalog({
components: { /* ... */ },
validationFunctions: {
isValidPhone: {
description: 'Validates phone number format',
},
isUniqueEmail: {
description: 'Checks if email is not already registered',
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mb-4">
Then implement them in your ValidationProvider:
</p>
<Code lang="tsx">{`import { ValidationProvider } from '@json-render/react';
function App() {
const customValidators = {
isValidPhone: (value) => {
const phoneRegex = /^\\+?[1-9]\\d{1,14}$/;
return phoneRegex.test(value);
},
isUniqueEmail: async (value) => {
const response = await fetch(\`/api/check-email?email=\${value}\`);
const { available } = await response.json();
return available;
},
};
return (
<ValidationProvider functions={customValidators}>
{/* Your UI */}
</ValidationProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useFieldValidation } from '@json-render/react';
function TextField({ element }) {
const { value, setValue, errors, validate } = useFieldValidation(
element.props.valuePath,
element.props.checks
);
return (
<div>
<label>{element.props.label}</label>
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
onBlur={() => validate()}
/>
{errors.map((error, i) => (
<p key={i} className="text-red-500 text-sm">{error}</p>
))}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Validation Timing</h2>
<p className="text-sm text-muted-foreground mb-4">
Control when validation runs with{" "}
<code className="text-foreground">validateOn</code>:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
<li>
<code className="text-foreground">change</code> — Validate on every
input change
</li>
<li>
<code className="text-foreground">blur</code> — Validate when field
loses focus
</li>
<li>
<code className="text-foreground">submit</code> — Validate only on
form submission
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/ai-sdk" className="text-foreground hover:underline">
AI SDK integration
</Link>
.
</p>
</article>
);
}
+147
View File
@@ -0,0 +1,147 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Visibility | json-render",
};
export default function VisibilityPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Visibility</h1>
<p className="text-muted-foreground mb-8">
Conditionally show or hide components based on data, auth, or logic.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">VisibilityProvider</h2>
<p className="text-sm text-muted-foreground mb-4">
Wrap your app with VisibilityProvider to enable conditional rendering:
</p>
<Code lang="tsx">{`import { VisibilityProvider } from '@json-render/react';
function App() {
return (
<DataProvider initialData={data}>
<VisibilityProvider>
{/* Components can now use visibility conditions */}
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Path-Based Visibility
</h2>
<p className="text-sm text-muted-foreground mb-4">
Show/hide based on data values:
</p>
<Code lang="json">{`{
"type": "Alert",
"props": { "message": "Form has errors" },
"visible": { "path": "/form/hasErrors" }
}
// Visible when /form/hasErrors is truthy`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Auth-Based Visibility
</h2>
<p className="text-sm text-muted-foreground mb-4">
Show/hide based on authentication state:
</p>
<Code lang="json">{`{
"type": "AdminPanel",
"visible": { "auth": "signedIn" }
}
// Options: "signedIn", "signedOut", "admin", etc.`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Logic Expressions</h2>
<p className="text-sm text-muted-foreground mb-4">
Combine conditions with logic operators:
</p>
<Code lang="json">{`// AND - all conditions must be true
{
"type": "SubmitButton",
"visible": {
"and": [
{ "path": "/form/isValid" },
{ "path": "/form/hasChanges" }
]
}
}
// OR - any condition must be true
{
"type": "HelpText",
"visible": {
"or": [
{ "path": "/user/isNew" },
{ "path": "/settings/showHelp" }
]
}
}
// NOT - invert a condition
{
"type": "WelcomeBanner",
"visible": {
"not": { "path": "/user/hasSeenWelcome" }
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Comparison Operators</h2>
<Code lang="json">{`// Equal
{
"visible": {
"eq": [{ "path": "/user/role" }, "admin"]
}
}
// Greater than
{
"visible": {
"gt": [{ "path": "/cart/total" }, 100]
}
}
// Available: eq, ne, gt, gte, lt, lte`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Complex Example</h2>
<Code lang="json">{`{
"type": "RefundButton",
"props": { "label": "Process Refund" },
"visible": {
"and": [
{ "auth": "signedIn" },
{ "eq": [{ "path": "/user/role" }, "support"] },
{ "gt": [{ "path": "/order/amount" }, 0] },
{ "not": { "path": "/order/isRefunded" } }
]
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using in Components</h2>
<Code lang="tsx">{`import { useIsVisible } from '@json-render/react';
function ConditionalContent({ element, children }) {
const isVisible = useIsVisible(element.visible);
if (!isVisible) return null;
return <div>{children}</div>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/validation"
className="text-foreground hover:underline"
>
form validation
</Link>
.
</p>
</article>
);
}
+19 -103
View File
@@ -1,20 +1,10 @@
@import "@vercel/geistdocs/styles.css";
@import "tailwindcss";
@import "tw-animate-css";
@theme {
--breakpoint-sm: 40rem;
--breakpoint-md: 48rem;
--breakpoint-lg: 64rem;
--breakpoint-xl: 80rem;
--breakpoint-2xl: 96rem;
}
@source "../node_modules/streamdown/dist/index.js";
@custom-variant dark (&:is(.dark-theme *));
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.5rem;
--ds-gray-500: oklch(0.836 0 0);
/* Monochrome light theme */
--background: oklch(1.0 0 0);
--foreground: oklch(0.1 0 0);
@@ -35,11 +25,9 @@
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
}
.dark-theme {
--ds-gray-500: oklch(0.39 0 0);
.dark {
/* Monochrome dark theme */
--background: oklch(0.0 0 0);
--foreground: oklch(0.98 0 0);
@@ -60,7 +48,6 @@
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
}
@theme inline {
@@ -118,77 +105,29 @@
@apply bg-transparent p-0;
}
/* Hide page scrollbar */
html {
scrollbar-width: none;
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
html::-webkit-scrollbar {
display: none;
::-webkit-scrollbar-track {
@apply bg-background;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground;
}
}
button {
cursor: pointer;
}
/* Tool call shimmer animation */
@keyframes tool-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.animate-tool-shimmer {
animation: tool-shimmer 1.5s ease-in-out infinite;
}
/* Fix list rendering in chat content */
.docs-chat-content ul,
.docs-chat-content ol {
list-style-position: outside;
padding-left: 1.25em;
}
.docs-chat-content li > p {
display: inline;
margin: 0;
}
.docs-chat-content li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
.mdx-table th,
.mdx-table td,
article table th,
article table td {
border: 1px solid var(--border);
padding: 0.75rem 1rem;
text-align: left;
}
.mdx-table th,
article table th {
font-weight: 600;
background-color: var(--muted);
}
.mdx-table td,
article table td {
color: var(--muted-foreground);
}
article table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
margin: 1.5rem 0;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
@@ -196,31 +135,8 @@ article table {
background-color: var(--shiki-light-bg) !important;
}
.dark-theme .shiki,
.dark-theme .shiki span {
.dark .shiki,
.dark .shiki span {
color: var(--shiki-dark) !important;
background-color: var(--shiki-dark-bg) !important;
}
@container (width < 1200px) {
#nd-page { @apply px-6 pt-6; }
#nd-page > [data-mobile-docs-bar],
#nd-page > div:has([data-mobile-toc-trigger]) { display: flex; }
#nd-page [data-mobile-toc-trigger] { width: 44px; height: 44px; }
#nd-page > div:has(> h1) { padding-inline-end: 3.5rem; }
#nd-page > div:has(> h1) + div,
#nd-page > div:has(> h1) + p + div { margin-top: 0; }
}
@container (961px <= width < 1200px) {
#nd-page > [data-mobile-docs-bar] { display: none; }
}
header .pointer-events-none.opacity-0 {
visibility: hidden;
}
#nd-page div:has(> [aria-live="polite"]) > div > .max-sm\:hidden {
display: flex;
}
+19 -60
View File
@@ -1,18 +1,11 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { Header } from "@/components/header";
import { Footer } from "@/components/footer";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsProvider } from "@/components/geistdocs-provider";
import { Navbar } from "@vercel/geistdocs/navbar";
import { Footer } from "@vercel/geistdocs/footer";
import { config } from "@/lib/geistdocs/config";
import { DocsChat } from "@/components/docs-chat";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { PAGE_TITLES } from "@/lib/page-titles";
import { cookies } from "next/headers";
import { isPreview, siteUrl, siteDescription } from "@/lib/site";
const geistSans = localFont({
src: "./fonts/GeistVF.woff",
@@ -24,21 +17,17 @@ const geistMono = localFont({
});
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
alternates: { canonical: "/" },
metadataBase: new URL("https://json-render.dev"),
title: {
default: `json-render | ${PAGE_TITLES[""]}`,
default: "json-render | AI-generated UI with guardrails",
template: "%s | json-render",
},
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
keywords: [
"json-render",
"generative UI",
"AI UI generation",
"user-generated interfaces",
"React components",
"React Native",
"guardrails",
"structured output",
"dashboard builder",
@@ -50,79 +39,49 @@ export const metadata: Metadata = {
locale: "en_US",
url: "https://json-render.dev",
siteName: "json-render",
title: "json-render | The Generative UI Framework",
title: "json-render | AI-generated UI with guardrails",
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
images: [
{
url: "/og",
width: 1200,
height: 630,
alt: "json-render - The Generative UI Framework",
alt: "json-render - AI-generated UI with guardrails",
},
],
},
twitter: {
card: "summary_large_image",
title: "json-render | The Generative UI Framework",
title: "json-render | AI-generated UI with guardrails",
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
"Let users generate dashboards, widgets, apps, and data visualizations from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
robots: {
index: !isPreview,
follow: !isPreview,
index: true,
follow: true,
},
icons: {
icon: "/favicon.ico",
},
};
export default async function RootLayout({
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const cookieStore = await cookies();
const chatOpen = cookieStore.get("docs-chat-open")?.value === "true";
const chatWidth = Math.min(
700,
Math.max(300, Number(cookieStore.get("docs-chat-width")?.value) || 400),
);
return (
<html lang="en" suppressHydrationWarning>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "json-render",
url: siteUrl,
description: siteDescription,
}),
}}
/>
{chatOpen && (
<style
dangerouslySetInnerHTML={{
__html: `@media(min-width:640px){body{padding-right:min(${chatWidth}px, calc(100vw - 320px))}}`,
}}
/>
)}
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>
<DocsProvider>
<Navbar config={config} />
{children}
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</DocsProvider>
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</div>
</ThemeProvider>
<Analytics />
<SpeedInsights />
-10
View File
@@ -1,10 +0,0 @@
import { loadAllDocsSources } from "@/lib/docs-source";
import { siteDescription, siteUrl } from "@/lib/site";
export async function GET() {
const pages = await loadAllDocsSources();
const body = `# json-render\n\n${siteDescription}\n\n## Documentation\n\n${pages.map((page) => `- [${page.title}](${siteUrl}${page.markdownUrl})`).join("\n")}\n`;
return new Response(body, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
-22
View File
@@ -1,22 +0,0 @@
import Link from "next/link";
import { Header } from "@/components/header";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="flex flex-1 flex-col items-center justify-center gap-4 px-4 text-center">
<h1 className="text-6xl font-bold tracking-tight">404</h1>
<p className="text-lg text-muted-foreground">
This page could not be found.
</p>
<Link
href="/"
className="mt-2 inline-flex items-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors"
>
Go home
</Link>
</main>
</div>
);
}
-16
View File
@@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { getPageTitle, renderOgImage } from "../og-image";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string[] }> },
) {
const { slug } = await params;
const title = getPageTitle(slug.join("/"));
if (!title) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return renderOgImage(title);
}
-112
View File
@@ -1,112 +0,0 @@
import { ImageResponse } from "next/og";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
export { getPageTitle } from "@/lib/page-titles";
// Cache font data in memory after first load
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
async function loadFonts() {
if (fontCache) return fontCache;
const [geistRegular, geistPixelSquare] = await Promise.all([
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
]);
fontCache = { geistRegular, geistPixelSquare };
return fontCache;
}
export async function renderOgImage(title: string) {
const { geistRegular, geistPixelSquare } = await loadFonts();
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
backgroundColor: "black",
padding: "60px 80px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "16px",
}}
>
<svg width="36" height="36" viewBox="0 0 16 16" fill="white">
<path fillRule="evenodd" clipRule="evenodd" d="M8 1L16 15H0L8 1Z" />
</svg>
<span
style={{
fontSize: 36,
color: "#666",
fontFamily: "Geist",
fontWeight: 400,
}}
>
/
</span>
<span
style={{
fontSize: 36,
fontFamily: "Geist Pixel Square",
fontWeight: 500,
color: "white",
}}
>
json-render
</span>
</div>
<div
style={{
display: "flex",
flex: 1,
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
}}
>
{title.split("\n").map((line, i) => (
<span
key={i}
style={{
fontSize: 72,
fontFamily: "Geist",
fontWeight: 400,
color: "white",
letterSpacing: "-0.02em",
textAlign: "center",
lineHeight: 1.2,
}}
>
{line}
</span>
))}
</div>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "Geist",
data: geistRegular.buffer as ArrayBuffer,
style: "normal",
weight: 400,
},
{
name: "Geist Pixel Square",
data: geistPixelSquare.buffer as ArrayBuffer,
style: "normal",
weight: 500,
},
],
},
);
}
+42 -4
View File
@@ -1,6 +1,44 @@
import { getPageTitle, renderOgImage } from "./og-image";
import { ImageResponse } from "next/og";
export async function GET() {
const title = getPageTitle("")!;
return renderOgImage(title);
export async function GET(request: Request) {
const geist = await fetch(new URL("/Geist-Regular.ttf", request.url)).then(
(res) => res.arrayBuffer(),
);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: "black",
}}
>
<span
style={{
fontSize: 144,
fontFamily: "Geist",
fontWeight: 400,
color: "white",
letterSpacing: "-0.02em",
}}
>
json-render
</span>
</div>,
{
width: 1200,
height: 630,
fonts: [
{
name: "Geist",
data: geist,
style: "normal",
weight: 400,
},
],
},
);
}
@@ -9,16 +9,12 @@ export default function Home() {
<>
{/* Hero */}
<section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
<p className="text-xs sm:text-sm font-medium text-muted-foreground tracking-widest uppercase mb-4">
The Generative UI Framework
</p>
<h1 className="text-4xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
AI → json-render → UI
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
Predictable. Guardrailed. Fast.
</h1>
<p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
Generate dynamic, personalized UIs from prompts without sacrificing
reliability. Predefined components and actions for safe, predictable
output.
Let users generate dashboards, widgets, apps, and data visualizations
from prompts — safely constrained to components you define.
</p>
<Demo />
@@ -69,10 +65,10 @@ export default function Home() {
<div className="text-xs text-muted-foreground font-mono mb-3">
02
</div>
<h3 className="text-lg font-semibold mb-2">AI Generates</h3>
<h3 className="text-lg font-semibold mb-2">Users Prompt</h3>
<p className="text-sm text-muted-foreground leading-relaxed">
Describe what you want. AI generates JSON constrained to your
catalog. Every interface is unique.
End users describe what they want. AI generates JSON constrained
to your catalog.
</p>
</div>
<div>
@@ -100,12 +96,10 @@ export default function Home() {
<p className="text-muted-foreground mb-6">
Components, actions, and validation functions.
</p>
<Code lang="typescript">{`import { defineSchema, defineCatalog } from '@json-render/core';
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
const schema = defineSchema({ /* ... */ });
export const catalog = defineCatalog(schema, {
export const catalog = createCatalog({
components: {
Card: {
props: z.object({
@@ -117,7 +111,7 @@ export const catalog = defineCatalog(schema, {
Metric: {
props: z.object({
label: z.string(),
statePath: z.string(),
valuePath: z.string(),
format: z.enum(['currency', 'percent']),
}),
},
@@ -133,24 +127,23 @@ export const catalog = defineCatalog(schema, {
Constrained output that your components render natively.
</p>
<Code lang="json">{`{
"root": "dashboard",
"elements": {
"dashboard": {
"type": "Card",
"props": {
"title": "Revenue Dashboard"
},
"children": ["revenue"]
},
"revenue": {
"key": "dashboard",
"type": "Card",
"props": {
"title": "Revenue Dashboard",
"description": null
},
"children": [
{
"key": "revenue",
"type": "Metric",
"props": {
"label": "Total Revenue",
"statePath": "/metrics/revenue",
"valuePath": "/metrics/revenue",
"format": "currency"
}
}
}
]
}`}</Code>
</div>
</div>
@@ -177,22 +170,25 @@ export const catalog = defineCatalog(schema, {
"root": "card",
"elements": {
"card": {
"key": "card",
"type": "Card",
"props": { "title": "Revenue" },
"children": ["metric", "chart"]
},
"metric": {
"key": "metric",
"type": "Metric",
"props": {
"label": "Total Revenue",
"statePath": "analytics/revenue",
"valuePath": "analytics/revenue",
"format": "currency"
}
},
"chart": {
"key": "chart",
"type": "Chart",
"props": {
"statePath": "analytics/salesByRegion"
"dataPath": "analytics/salesByRegion"
}
}
}
@@ -225,10 +221,10 @@ export default function Page() {
<Metric
data={data}
label="Total Revenue"
statePath="analytics/revenue"
valuePath="analytics/revenue"
format="currency"
/>
<Chart data={data} statePath="analytics/salesByRegion" />
<Chart data={data} dataPath="analytics/salesByRegion" />
</Card>
);
}`}</Code>
@@ -250,10 +246,6 @@ export default function Page() {
<h2 className="text-2xl font-semibold mb-12 text-center">Features</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-8">
{[
{
title: "Generative UI",
desc: "Generate dynamic, personalized interfaces from prompts with AI",
},
{
title: "Guardrails",
desc: "AI can only use components you define in the catalog",
@@ -263,16 +255,20 @@ export default function Page() {
desc: "Progressive rendering as JSON streams from the model",
},
{
title: "React & React Native",
desc: "Render on web and mobile from the same catalog and spec format",
title: "Code Export",
desc: "Export as standalone React code with no runtime dependencies",
},
{
title: "Data Binding",
desc: "Connect props to state with $state, $item, $index, and two-way binding",
desc: "Two-way binding with JSON Pointer paths",
},
{
title: "Code Export",
desc: "Export as standalone React code with no runtime dependencies",
title: "Actions",
desc: "Named actions handled by your application",
},
{
title: "Visibility",
desc: "Conditional show/hide based on data or auth",
},
].map((feature) => (
<div key={feature.title}>
-11
View File
@@ -1,11 +0,0 @@
export default function PlaygroundLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<main className="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden">
{children}
</main>
);
}
+68 -4
View File
@@ -1,8 +1,72 @@
import { Playground } from "@/components/playground";
import { pageMetadata } from "@/lib/page-metadata";
import { Button } from "@/components/ui/button";
export const metadata = pageMetadata("playground");
export const metadata = {
title: "Playground | json-render",
};
export default function PlaygroundPage() {
return <Playground />;
return (
<div className="max-w-4xl mx-auto px-6 py-16">
<h1 className="text-3xl font-bold mb-4">Playground</h1>
<p className="text-muted-foreground mb-12">
Try json-render with a live example.
</p>
<div className="space-y-12">
<section>
<h2 className="text-xl font-semibold mb-4">Run locally</h2>
<p className="text-sm text-muted-foreground mb-4">
Clone the repository and run the example dashboard.
</p>
<pre className="text-sm mb-4">
<code>{`git clone https://github.com/vercel-labs/json-render
cd json-render
pnpm install
pnpm dev`}</code>
</pre>
<p className="text-sm text-muted-foreground">
Open <code>http://localhost:3001</code> for the example dashboard.
</p>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Example prompts</h2>
<p className="text-sm text-muted-foreground mb-4">
Try these prompts in the example dashboard:
</p>
<div className="space-y-2">
{[
"Create a revenue dashboard with monthly metrics",
"Build a user management panel with a table",
"Design a settings form with text inputs",
"Make a notification center with alerts",
].map((prompt) => (
<div
key={prompt}
className="p-3 border border-border rounded text-sm font-mono"
>
{prompt}
</div>
))}
</div>
</section>
<section>
<h2 className="text-xl font-semibold mb-4">Interactive playground</h2>
<p className="text-sm text-muted-foreground mb-6">
A browser-based playground is coming soon.
</p>
<Button variant="outline" asChild>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
>
Star on GitHub
</a>
</Button>
</section>
</div>
</div>
);
}
-11
View File
@@ -1,11 +0,0 @@
import type { MetadataRoute } from "next";
import { isPreview, siteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: isPreview
? { userAgent: "*", disallow: "/" }
: { userAgent: "*", allow: "/" },
sitemap: `${siteUrl}/sitemap.xml`,
};
}
-10
View File
@@ -1,10 +0,0 @@
import { docsPages } from "@/lib/docs-source";
export function GET() {
return new Response(
`# json-render documentation\n\n${docsPages.map((page) => `- [${page.title}](${page.href})`).join("\n")}\n`,
{
headers: { "Content-Type": "text/markdown; charset=utf-8" },
},
);
}
-9
View File
@@ -1,9 +0,0 @@
import type { MetadataRoute } from "next";
import { PAGE_TITLES } from "@/lib/page-titles";
import { siteUrl } from "@/lib/site";
export default function sitemap(): MetadataRoute.Sitemap {
return Object.keys(PAGE_TITLES).map((slug) => ({
url: `${siteUrl}/${slug}`,
}));
}
+3 -3
View File
@@ -145,7 +145,7 @@ function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [vercelLightTheme, vercelDarkTheme],
langs: ["json", "tsx", "typescript", "yaml"],
langs: ["json", "tsx", "typescript"],
});
}
return highlighterPromise;
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
interface CodeBlockProps {
code: string;
lang: "json" | "tsx" | "typescript" | "yaml";
lang: "json" | "tsx" | "typescript";
fillHeight?: boolean;
hideCopyButton?: boolean;
}
@@ -201,7 +201,7 @@ export function CodeBlock({
</div>
)}
<div
className="text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[13px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[13px]!"
className="text-[11px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_pre]:border-none! [&_pre]:rounded-none! [&_pre]:text-[11px]! [&_pre]:overflow-visible! [&_code]:bg-transparent! [&_code]:p-0! [&_code]:rounded-none! [&_code]:text-[11px]!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
+4 -7
View File
@@ -1,6 +1,5 @@
import { codeToHtml } from "shiki";
import { CopyButton } from "./copy-button";
import { ExpandableCode } from "./expandable-code";
const vercelDarkTheme = {
name: "vercel-dark",
@@ -159,12 +158,10 @@ export async function Code({ children, lang = "typescript" }: CodeProps) {
className="opacity-0 group-hover:opacity-100 text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-[#0a0a0a]"
/>
</div>
<ExpandableCode>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</ExpandableCode>
<div
className="overflow-x-auto [&_pre]:bg-transparent! [&_pre]:m-0! [&_pre]:p-4! [&_code]:bg-transparent! [&_.shiki]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
);
}
-71
View File
@@ -1,71 +0,0 @@
"use client";
import { useState } from "react";
import { usePathname } from "next/navigation";
export function CopyPageButton() {
const pathname = usePathname();
const [state, setState] = useState<"idle" | "loading" | "copied">("idle");
const handleCopy = async () => {
setState("loading");
try {
const response = await fetch(
`/api/docs-markdown?path=${encodeURIComponent(pathname)}`,
);
if (!response.ok) {
throw new Error("Failed to fetch markdown");
}
const markdown = await response.text();
await navigator.clipboard.writeText(markdown);
setState("copied");
setTimeout(() => setState("idle"), 2000);
} catch {
setState("idle");
}
};
return (
<button
onClick={handleCopy}
disabled={state === "loading"}
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs text-muted-foreground hover:text-foreground border border-border rounded-md hover:bg-muted transition-colors disabled:opacity-50"
aria-label="Copy page as Markdown"
>
{state === "copied" ? (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
Copied
</>
) : (
<>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
Copy Page
</>
)}
</button>
);
}
+192 -422
View File
@@ -7,241 +7,168 @@ import React, {
useRef,
useMemo,
} from "react";
import { useUIStream } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { Renderer, useUIStream, JSONUIProvider } from "@json-render/react";
import type { UITree } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import { CodeBlock } from "./code-block";
import { CopyButton } from "./copy-button";
import { Toaster } from "./ui/sonner";
import { PlaygroundRenderer } from "@/lib/render/renderer";
import { playgroundCatalog } from "@/lib/render/catalog";
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
import {
demoRegistry,
fallbackComponent,
useInteractiveState,
} from "./demo/index";
const SIMULATION_PROMPT = "Show a team performance dashboard";
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
interface SimulationStage {
tree: Spec;
tree: UITree;
stream: string;
}
const DASH_STATE = {
chartData: [
{ label: "Mon", value: 12 },
{ label: "Tue", value: 28 },
{ label: "Wed", value: 19 },
{ label: "Thu", value: 34 },
{ label: "Fri", value: 45 },
{ label: "Sat", value: 38 },
{ label: "Sun", value: 52 },
],
};
const METRIC_REVENUE = {
type: "Metric",
props: {
label: "Weekly Revenue",
value: "12,400",
prefix: "$",
change: "+18%",
changeType: "positive",
},
} as const;
const CHART = {
type: "LineGraph",
props: { data: { $state: "/chartData" } },
} as const;
const SEP = { type: "Separator", props: {} } as const;
const PROGRESS_DEALS = {
type: "Progress",
props: { value: 72, label: "Deals Closed -- 72%" },
} as const;
const PROGRESS_RETENTION = {
type: "Progress",
props: { value: 91, label: "Retention -- 91%" },
} as const;
const SIMULATION_STAGES: SimulationStage[] = [
{
tree: {
root: "card",
state: DASH_STATE,
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Team Performance", maxWidth: "sm", centered: true },
props: { title: "Contact Us", maxWidth: "md" },
children: [],
},
},
},
stream: '{"op":"add","path":"/root","value":"card"}',
stream: '{"op":"set","path":"/root","value":"card"}',
},
{
tree: {
root: "card",
state: DASH_STATE,
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Team Performance", maxWidth: "sm", centered: true },
children: ["m1"],
props: { title: "Contact Us", maxWidth: "md" },
children: ["name"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
m1: METRIC_REVENUE,
},
},
stream:
'{"op":"add","path":"/elements/m1","value":{"type":"Metric","props":{"label":"Weekly Revenue","value":"12,400","prefix":"$","change":"+18%","changeType":"positive"}}}',
'{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
},
{
tree: {
root: "card",
state: DASH_STATE,
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Team Performance", maxWidth: "sm", centered: true },
children: ["m1", "chart"],
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
m1: METRIC_REVENUE,
chart: CHART,
},
},
stream:
'{"op":"add","path":"/elements/chart","value":{"type":"LineGraph","props":{"data":{"$state":"/chartData"}}}}',
'{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
},
{
tree: {
root: "card",
state: DASH_STATE,
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Team Performance", maxWidth: "sm", centered: true },
children: ["m1", "chart", "sep", "p1"],
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
key: "message",
type: "Textarea",
props: { label: "Message", name: "message" },
},
m1: METRIC_REVENUE,
chart: CHART,
sep: SEP,
p1: PROGRESS_DEALS,
},
},
stream:
'{"op":"add","path":"/elements/p1","value":{"type":"Progress","props":{"value":72,"label":"Deals Closed -- 72%"}}}',
'{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
},
{
tree: {
root: "card",
state: DASH_STATE,
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Team Performance", maxWidth: "sm", centered: true },
children: ["m1", "chart", "sep", "p1", "p2"],
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message", "submit"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
key: "message",
type: "Textarea",
props: { label: "Message", name: "message" },
},
submit: {
key: "submit",
type: "Button",
props: { label: "Send Message", variant: "primary" },
},
m1: METRIC_REVENUE,
chart: CHART,
sep: SEP,
p1: PROGRESS_DEALS,
p2: PROGRESS_RETENTION,
},
},
stream:
'{"op":"add","path":"/elements/p2","value":{"type":"Progress","props":{"value":91,"label":"Retention -- 91%"}}}',
'{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
},
];
type Mode = "simulation" | "interactive";
type Phase = "typing" | "streaming" | "complete";
type Tab = "stream" | "json" | "nested" | "catalog";
type Tab = "stream" | "json";
type RenderView = "dynamic" | "static";
interface DemoProps {
fullscreen?: boolean;
skipSimulation?: boolean;
}
/**
* Convert a flat Spec into a nested tree structure that is easier for humans
* to read. Children keys are resolved recursively into inline objects.
*/
function specToNested(spec: Spec): Record<string, unknown> {
function resolve(key: string): Record<string, unknown> {
const el = spec.elements[key];
if (!el) return { _key: key, _missing: true };
const node: Record<string, unknown> = { type: el.type };
if (el.props && Object.keys(el.props).length > 0) {
node.props = el.props;
}
if (el.visible !== undefined) {
node.visible = el.visible;
}
if (el.on && Object.keys(el.on).length > 0) {
node.on = el.on;
}
if (el.repeat) {
node.repeat = el.repeat;
}
if (el.children && el.children.length > 0) {
node.children = el.children.map(resolve);
}
if (el.slots && Object.keys(el.slots).length > 0) {
node.slots = Object.fromEntries(
Object.entries(el.slots).map(([slotName, childKeys]) => [
slotName,
childKeys.map(resolve),
]),
);
}
return node;
}
const result: Record<string, unknown> = {};
if (spec.state && Object.keys(spec.state).length > 0) {
result.state = spec.state;
}
result.elements = resolve(spec.root);
return result;
}
const EXAMPLE_PROMPTS = [
"Recipe card with rating and ingredients",
"Order receipt with item list and total",
"Team member profile card",
"Notification inbox with alerts",
];
export function Demo({
fullscreen = false,
skipSimulation = false,
}: DemoProps) {
const [mode, setMode] = useState<Mode>(
skipSimulation ? "interactive" : "simulation",
);
const [phase, setPhase] = useState<Phase>(
skipSimulation ? "complete" : "typing",
);
export function Demo() {
const [mode, setMode] = useState<Mode>("simulation");
const [phase, setPhase] = useState<Phase>("typing");
const [typedPrompt, setTypedPrompt] = useState("");
const [userPrompt, setUserPrompt] = useState("");
const [stageIndex, setStageIndex] = useState(-1);
const [streamLines, setStreamLines] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState<Tab>("json");
const [renderView, setRenderView] = useState<RenderView>("dynamic");
const [simulationTree, setSimulationTree] = useState<Spec | null>(null);
const [simulationTree, setSimulationTree] = useState<UITree | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [selectedExportFile, setSelectedExportFile] = useState<string | null>(
@@ -252,15 +179,6 @@ export function Demo({
new Set(),
);
const inputRef = useRef<HTMLInputElement>(null);
const [catalogSection, setCatalogSection] = useState<
"components" | "actions"
>("components");
// Catalog data for the catalog tab
const catalogData = useMemo(
() => buildCatalogDisplayData(playgroundCatalog.data),
[],
);
// Disable body scroll when any modal is open
useEffect(() => {
@@ -276,19 +194,18 @@ export function Demo({
// Use the library's useUIStream hook for real API calls
const {
spec: apiSpec,
tree: apiTree,
isStreaming,
send,
clear,
rawLines: apiRawLines,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
},
onError: (err: Error) => console.error("Generation error:", err),
} as Parameters<typeof useUIStream>[0]);
// Initialize interactive state for Select components
useInteractiveState();
const currentSimulationStage =
stageIndex >= 0 ? SIMULATION_STAGES[stageIndex] : null;
@@ -296,7 +213,7 @@ export function Demo({
const currentTree =
mode === "simulation"
? currentSimulationStage?.tree || simulationTree
: apiSpec || simulationTree;
: apiTree || simulationTree;
const stopGeneration = useCallback(() => {
if (mode === "simulation") {
@@ -353,12 +270,25 @@ export function Demo({
return () => clearInterval(interval);
}, [mode, phase]);
// Track stream lines from real API (use raw JSONL patch lines)
// Track stream lines from real API
useEffect(() => {
if (mode === "interactive" && apiRawLines.length > 0) {
setStreamLines(apiRawLines);
if (mode === "interactive" && apiTree) {
// Convert tree to stream line for display
const streamLine = JSON.stringify({ tree: apiTree });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiTree.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine !== streamLine) {
return [...prev, streamLine];
}
return prev;
});
}
}
}, [mode, apiRawLines]);
}, [mode, apiTree, streamLines]);
const handleSubmit = useCallback(async () => {
if (!userPrompt.trim() || isStreaming) return;
@@ -366,15 +296,23 @@ export function Demo({
await send(userPrompt);
}, [userPrompt, isStreaming, send]);
// Expose action handler for registry components - shows toast with text
useEffect(() => {
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction = (text: string) => {
toast(text);
};
return () => {
delete (window as unknown as { __demoAction?: (text: string) => void })
.__demoAction;
};
}, []);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
const nestedCode = useMemo(() => {
if (!currentTree || !currentTree.root) return "// waiting...";
return JSON.stringify(specToNested(currentTree), null, 2);
}, [currentTree]);
// Generate all export files for Next.js project
const exportedFiles = useMemo(() => {
if (!currentTree || !currentTree.root) {
@@ -402,45 +340,21 @@ export function Demo({
const propsStr = serializeProps(propsObj);
const hasChildren = element.children && element.children.length > 0;
const hasSlots = element.slots && Object.keys(element.slots).length > 0;
if (!hasChildren && !hasSlots) {
if (!hasChildren) {
return propsStr
? `${spaces}<${componentName} ${propsStr} />`
: `${spaces}<${componentName} />`;
}
const lines: string[] = [];
if (hasSlots) {
lines.push(`${spaces}<${componentName}`);
if (propsStr) {
lines.push(`${spaces} ${propsStr}`);
}
for (const [slotName, childKeys] of Object.entries(element.slots!)) {
const slotChildren = childKeys
.map((childKey) => generateJSX(childKey, indent + 2))
.filter(Boolean);
if (slotChildren.length === 0) continue;
lines.push(`${spaces} ${slotName}={`);
if (slotChildren.length > 1) {
lines.push(`${spaces} <>`);
}
lines.push(...slotChildren);
if (slotChildren.length > 1) {
lines.push(`${spaces} </>`);
}
lines.push(`${spaces} }`);
}
lines.push(`${spaces}>`);
} else {
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
}
lines.push(
propsStr
? `${spaces}<${componentName} ${propsStr}>`
: `${spaces}<${componentName}>`,
);
for (const childKey of element.children ?? []) {
for (const childKey of element.children!) {
lines.push(generateJSX(childKey, indent + 1));
}
@@ -995,25 +909,10 @@ Open [http://localhost:3000](http://localhost:3000) to view.
const isStreamingSimulation = mode === "simulation" && phase === "streaming";
const showLoadingDots = isStreamingSimulation || isStreaming;
const handleExampleClick = useCallback((prompt: string) => {
setMode("interactive");
setPhase("complete");
setUserPrompt(prompt);
setTimeout(() => {
const el = inputRef.current;
if (el) {
el.focus();
el.setSelectionRange(prompt.length, prompt.length);
}
}, 0);
}, []);
return (
<div
className={`w-full text-left ${fullscreen ? "h-full flex flex-col" : "max-w-5xl mx-auto"}`}
>
<div className="w-full max-w-4xl mx-auto text-left">
{/* Prompt input */}
<div className={fullscreen ? "mb-4" : "mb-6"}>
<div className="mb-6">
<div
className="border border-border rounded p-3 bg-background font-mono text-sm min-h-[44px] flex items-center justify-between cursor-text"
onClick={() => {
@@ -1101,40 +1000,17 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
)}
</div>
{fullscreen ? (
<div className="mt-3 flex flex-wrap gap-2 justify-center">
{EXAMPLE_PROMPTS.map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
) : (
<div className="mt-2 flex flex-wrap gap-1.5 justify-center">
{EXAMPLE_PROMPTS.slice(0, 2).map((prompt) => (
<button
key={prompt}
onClick={() => handleExampleClick(prompt)}
className="text-xs px-2 py-1 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
>
{prompt}
</button>
))}
</div>
)}
<div className="mt-2 text-xs text-muted-foreground text-center">
Try: &quot;Create a login form&quot; or &quot;Build a feedback form
with rating&quot;
</div>
</div>
<div
className={`grid lg:grid-cols-2 gap-4 ${fullscreen ? "flex-1 min-h-0" : ""}`}
>
<div className="grid lg:grid-cols-2 gap-4">
{/* Tabbed code/stream/json panel */}
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center gap-4 mb-2 h-6 shrink-0">
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
<div className="min-w-0">
<div className="flex items-center gap-4 mb-2 h-6">
{(["json", "stream"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -1148,25 +1024,17 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
))}
</div>
<div
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
>
{activeTab !== "catalog" && (
<div className="absolute top-2 right-2 z-10">
<CopyButton
text={
activeTab === "stream"
? streamLines.join("\n")
: activeTab === "nested"
? nestedCode
: jsonCode
}
className="opacity-0 group-hover:opacity-100 text-muted-foreground"
/>
</div>
)}
<div className="border border-border rounded bg-background font-mono text-xs h-96 text-left grid relative group">
<div className="absolute top-2 right-2 z-10">
<CopyButton
text={
activeTab === "stream" ? streamLines.join("\n") : jsonCode
}
className="opacity-0 group-hover:opacity-100 text-muted-foreground"
/>
</div>
<div
className={`overflow-auto ${activeTab === "stream" ? "" : "hidden"}`}
className={`overflow-auto h-full ${activeTab === "stream" ? "" : "hidden"}`}
>
{streamLines.length > 0 ? (
<>
@@ -1191,7 +1059,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
)}
</div>
<div
className={`overflow-auto ${activeTab === "json" ? "" : "hidden"}`}
className={`overflow-auto h-full ${activeTab === "json" ? "" : "hidden"}`}
>
<CodeBlock
code={jsonCode}
@@ -1200,142 +1068,12 @@ Open [http://localhost:3000](http://localhost:3000) to view.
hideCopyButton
/>
</div>
<div
className={`overflow-auto ${activeTab === "nested" ? "" : "hidden"}`}
>
<CodeBlock
code={nestedCode}
lang="json"
fillHeight
hideCopyButton
/>
</div>
<div
className={`overflow-auto ${activeTab === "catalog" ? "" : "hidden"}`}
>
<div className="h-full flex flex-col text-sm font-sans">
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
{(
[
{
key: "components",
label: `components (${catalogData.components.length})`,
},
{
key: "actions",
label: `actions (${catalogData.actions.length})`,
},
] as const
).map(({ key, label }) => (
<button
key={key}
onClick={() => setCatalogSection(key)}
className={`text-xs font-mono transition-colors ${
catalogSection === key
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{label}
</button>
))}
</div>
<div className="flex-1 overflow-auto p-3">
{catalogSection === "components" ? (
<div className="space-y-3">
{catalogData.components.map((comp) => (
<div
key={comp.name}
className="pb-3 border-b border-border last:border-b-0"
>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-mono font-medium text-foreground">
{comp.name}
</span>
{comp.slots.length > 0 && (
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
slots: {comp.slots.join(", ")}
</span>
)}
</div>
{comp.description && (
<p className="text-xs text-muted-foreground mb-2">
{comp.description}
</p>
)}
{comp.props.length > 0 && (
<div className="flex flex-wrap gap-1 mb-1">
{comp.props.map((p) => (
<span
key={p.name}
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
>
{p.name}
<span className="text-green-700/50 dark:text-green-400/50">
: {p.type}
</span>
</span>
))}
</div>
)}
{comp.events.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{comp.events.map((e) => (
<span
key={e}
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400"
>
on.{e}
</span>
))}
</div>
)}
</div>
))}
</div>
) : (
<div className="space-y-3">
{catalogData.actions.map((action) => (
<div
key={action.name}
className="pb-3 border-b border-border last:border-b-0"
>
<span className="font-mono font-medium text-foreground">
{action.name}
</span>
{action.description && (
<p className="text-xs text-muted-foreground mt-1 mb-2">
{action.description}
</p>
)}
{action.params.length > 0 && (
<div className="flex flex-wrap gap-1">
{action.params.map((p) => (
<span
key={p.name}
className="text-[11px] font-mono px-1.5 py-0.5 rounded bg-green-500/10 text-green-700 dark:text-green-400"
>
{p.name}
<span className="text-green-700/50 dark:text-green-400/50">
: {p.type}
</span>
</span>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
{/* Rendered output using json-render */}
<div className={`min-w-0 ${fullscreen ? "flex flex-col" : ""}`}>
<div className="flex items-center justify-between mb-2 h-6 shrink-0">
<div className="min-w-0">
<div className="flex items-center justify-between mb-2 h-6">
<div className="flex items-center gap-4">
{(
[
@@ -1388,9 +1126,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</button>
</div>
</div>
<div
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
>
<div className="border border-border rounded bg-background h-96 grid relative group">
{renderView === "static" && (
<div className="absolute top-2 right-2 z-10">
<CopyButton
@@ -1403,10 +1139,28 @@ Open [http://localhost:3000](http://localhost:3000) to view.
<div className="overflow-auto">
{currentTree && currentTree.root ? (
<div className="animate-in fade-in duration-200 w-full min-h-full flex items-center justify-center p-3 py-4">
<PlaygroundRenderer
spec={currentTree}
loading={isStreaming || isStreamingSimulation}
/>
<JSONUIProvider
registry={
demoRegistry as Parameters<
typeof JSONUIProvider
>[0]["registry"]
}
>
<Renderer
tree={currentTree}
registry={
demoRegistry as Parameters<
typeof Renderer
>[0]["registry"]
}
loading={isStreaming || isStreamingSimulation}
fallback={
fallbackComponent as Parameters<
typeof Renderer
>[0]["fallback"]
}
/>
</JSONUIProvider>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
@@ -1457,10 +1211,26 @@ Open [http://localhost:3000](http://localhost:3000) to view.
<div className="flex-1 overflow-auto p-6">
{currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center">
<PlaygroundRenderer
spec={currentTree}
loading={isStreaming || isStreamingSimulation}
/>
<JSONUIProvider
registry={
demoRegistry as Parameters<
typeof JSONUIProvider
>[0]["registry"]
}
>
<Renderer
tree={currentTree}
registry={
demoRegistry as Parameters<typeof Renderer>[0]["registry"]
}
loading={isStreaming || isStreamingSimulation}
fallback={
fallbackComponent as Parameters<
typeof Renderer
>[0]["fallback"]
}
/>
</JSONUIProvider>
</div>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50 text-sm">
+29
View File
@@ -0,0 +1,29 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Alert({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const alertType = props.type as string;
const alertClass =
alertType === "success"
? "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-900 dark:text-green-100"
: alertType === "warning"
? "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100"
: alertType === "error"
? "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100"
: "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100";
return (
<div
className={`p-2 rounded border ${alertClass} ${baseClass} ${customClass}`}
>
<div className="text-xs font-medium">{props.title as string}</div>
{props.message ? (
<div className="text-[10px] mt-0.5">{props.message as string}</div>
) : null}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Avatar({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const name = (props.name as string) || "?";
const initials = name
.split(" ")
.map((n) => n[0])
.join("")
.slice(0, 2)
.toUpperCase();
const avatarSize =
props.size === "lg"
? "w-10 h-10 text-sm"
: props.size === "sm"
? "w-6 h-6 text-[8px]"
: "w-8 h-8 text-[10px]";
return (
<div
className={`${avatarSize} rounded-full bg-muted flex items-center justify-center font-medium ${baseClass} ${customClass}`}
>
{initials}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Badge({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const badgeVariant = props.variant as string;
const badgeClass =
badgeVariant === "success"
? "bg-green-100 text-green-800"
: badgeVariant === "warning"
? "bg-yellow-100 text-yellow-800"
: badgeVariant === "danger"
? "bg-red-100 text-red-800"
: "bg-muted text-foreground";
return (
<span
className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${badgeClass} ${baseClass} ${customClass}`}
>
{props.text as string}
</span>
);
}
+44
View File
@@ -0,0 +1,44 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function BarGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value), 1);
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="flex gap-1">
{data.map((d, i) => (
<div key={i} className="flex-1 flex flex-col items-center gap-1">
<div className="text-[8px] text-muted-foreground">{d.value}</div>
<div className="w-full h-20 flex items-end">
<div
className="w-full bg-foreground/80 rounded-t transition-all"
style={{
height: `${(d.value / maxValue) * 100}%`,
minHeight: 2,
}}
/>
</div>
<div className="text-[8px] text-muted-foreground truncate w-full text-center">
{d.label}
</div>
</div>
))}
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Button({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const variant = props.variant as string;
const label = props.label as string;
const actionText = (props.actionText as string) || label;
const btnClass =
variant === "danger"
? "bg-red-500 text-white"
: variant === "secondary"
? "bg-card border border-border text-foreground"
: "bg-foreground text-background";
return (
<button
type="button"
onClick={() =>
(
window as unknown as { __demoAction?: (text: string) => void }
).__demoAction?.(actionText)
}
className={`self-start px-3 py-1.5 rounded text-xs font-medium hover:opacity-90 transition-opacity ${btnClass} ${baseClass} ${customClass}`}
>
{label}
</button>
);
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Card({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const maxWidthClass =
props.maxWidth === "sm"
? "max-w-xs sm:min-w-[280px]"
: props.maxWidth === "md"
? "max-w-sm sm:min-w-[320px]"
: props.maxWidth === "lg"
? "max-w-md sm:min-w-[360px]"
: "w-full";
const centeredClass = props.centered ? "mx-auto" : "";
return (
<div
className={`border border-border rounded-lg p-3 bg-background overflow-hidden ${maxWidthClass} ${centeredClass} ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-1 text-left">
{props.title as string}
</div>
) : null}
{props.description ? (
<div className="text-[10px] text-muted-foreground mb-2 text-left">
{props.description as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Checkbox({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-sm flex items-center justify-center transition-colors ${checked ? "bg-foreground" : "bg-background"}`}
>
{checked && (
<svg
className="w-2.5 h-2.5 text-background"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
)}
</div>
{props.label as string}
</label>
);
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Divider({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return <hr className={`border-border my-2 ${baseClass} ${customClass}`} />;
}
+15
View File
@@ -0,0 +1,15 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Fallback({ element }: ComponentRenderProps) {
const customClass = getCustomClass(element.props);
return (
<div
className={`text-[10px] text-muted-foreground ${baseClass} ${customClass}`}
>
[{element.type}]
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Form({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div
className={`border border-border rounded-lg p-3 bg-background ${baseClass} ${customClass}`}
>
{props.title ? (
<div className="font-semibold text-sm mb-2 text-left">
{props.title as string}
</div>
) : null}
<div className="space-y-2">{children}</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Grid({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomCols = customClass.includes("grid-cols-");
const cols = hasCustomCols
? ""
: props.columns === 4
? "grid-cols-4"
: props.columns === 3
? "grid-cols-3"
: props.columns === 2
? "grid-cols-2"
: "grid-cols-1";
const gridGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div className={`grid ${cols} ${gridGap} ${baseClass} ${customClass}`}>
{children}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Heading({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const level = (props.level as number) || 2;
const headingClass =
level === 1
? "text-lg font-bold"
: level === 3
? "text-xs font-semibold"
: level === 4
? "text-[10px] font-semibold"
: "text-sm font-semibold";
return (
<div className={`${headingClass} text-left ${baseClass} ${customClass}`}>
{props.text as string}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Image({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const hasCustomSize =
customClass.includes("w-") || customClass.includes("h-");
const imgStyle = hasCustomSize
? {}
: {
width: (props.width as number) || 80,
height: (props.height as number) || 60,
};
return (
<div
className={`bg-muted border border-border rounded flex items-center justify-center text-[10px] text-muted-foreground aspect-video ${baseClass} ${customClass}`}
style={imgStyle}
>
{(props.alt as string) || "img"}
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
export type { ComponentRenderProps, ComponentRegistry } from "./types";
export { useInteractiveState } from "./utils";
export { Alert } from "./alert";
export { Avatar } from "./avatar";
export { Badge } from "./badge";
export { BarGraph } from "./bar-graph";
export { Button } from "./button";
export { Card } from "./card";
export { Checkbox } from "./checkbox";
export { Divider } from "./divider";
export { Fallback } from "./fallback";
export { Form } from "./form";
export { Grid } from "./grid";
export { Heading } from "./heading";
export { Image } from "./image";
export { Input } from "./input";
export { LineGraph } from "./line-graph";
export { Link } from "./link";
export { Progress } from "./progress";
export { Radio } from "./radio";
export { Rating } from "./rating";
export { Select } from "./select";
export { Stack } from "./stack";
export { Switch } from "./switch";
export { Text } from "./text";
export { Textarea } from "./textarea";
import type { ComponentRegistry } from "./types";
import { Alert } from "./alert";
import { Avatar } from "./avatar";
import { Badge } from "./badge";
import { BarGraph } from "./bar-graph";
import { Button } from "./button";
import { Card } from "./card";
import { Checkbox } from "./checkbox";
import { Divider } from "./divider";
import { Fallback } from "./fallback";
import { Form } from "./form";
import { Grid } from "./grid";
import { Heading } from "./heading";
import { Image } from "./image";
import { Input } from "./input";
import { LineGraph } from "./line-graph";
import { Link } from "./link";
import { Progress } from "./progress";
import { Radio } from "./radio";
import { Rating } from "./rating";
import { Select } from "./select";
import { Stack } from "./stack";
import { Switch } from "./switch";
import { Text } from "./text";
import { Textarea } from "./textarea";
export const demoRegistry: ComponentRegistry = {
Alert,
Avatar,
Badge,
BarGraph,
Button,
Card,
Checkbox,
Divider,
Form,
Grid,
Heading,
Image,
Input,
LineGraph,
Link,
Progress,
Radio,
Rating,
Select,
Stack,
Switch,
Text,
Textarea,
};
export const fallbackComponent = Fallback;
+24
View File
@@ -0,0 +1,24 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Input({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<input
type={(props.type as string) || "text"}
placeholder={(props.placeholder as string) || ""}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
interface DataPoint {
label: string;
value: number;
}
export function LineGraph({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const data = (props.data as DataPoint[]) || [];
const title = props.title as string | undefined;
const maxValue = Math.max(...data.map((d) => d.value));
const minValue = Math.min(...data.map((d) => d.value));
const range = maxValue - minValue || 1;
// SVG dimensions with padding
const width = 300;
const height = 100;
const padding = { top: 10, right: 10, bottom: 10, left: 10 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
// Calculate points for the SVG path
const points = data.map((d, i) => {
const x =
padding.left +
(data.length > 1 ? (i / (data.length - 1)) * chartWidth : chartWidth / 2);
const y =
padding.top + chartHeight - ((d.value - minValue) / range) * chartHeight;
return { x, y, ...d };
});
const pathD =
points.length > 0
? `M ${points.map((p) => `${p.x} ${p.y}`).join(" L ")}`
: "";
return (
<div className={`${baseClass} ${customClass}`}>
{title ? (
<div className="text-xs font-medium mb-2 text-left">{title}</div>
) : null}
<div className="relative h-24">
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full">
{/* Grid lines */}
<line
x1={padding.left}
y1={padding.top + chartHeight / 2}
x2={width - padding.right}
y2={padding.top + chartHeight / 2}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={padding.top}
x2={width - padding.right}
y2={padding.top}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke="currentColor"
strokeOpacity="0.1"
strokeWidth="1"
/>
{/* Line */}
{pathD && (
<path
d={pathD}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-foreground/80"
/>
)}
{/* Points */}
{points.map((p, i) => (
<circle
key={i}
cx={p.x}
cy={p.y}
r="4"
className="fill-foreground"
/>
))}
</svg>
</div>
{data.length > 0 && (
<div className="flex justify-between mt-1">
{data.map((d, i) => (
<div
key={i}
className="text-[8px] text-muted-foreground text-center"
style={{ width: `${100 / data.length}%` }}
>
{d.label}
</div>
))}
</div>
)}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Link({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
return (
<span
className={`text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors underline-offset-2 hover:underline ${baseClass} ${customClass}`}
>
{props.label as string}
</span>
);
}
+26
View File
@@ -0,0 +1,26 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Progress({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const value = Math.min(100, Math.max(0, (props.value as number) || 0));
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-foreground rounded-full transition-all"
style={{ width: `${value}%` }}
/>
</div>
</div>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Radio({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const [selected, setSelected] = useState(0);
return (
<div className={`space-y-1 ${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
{options.map((opt, i) => (
<label
key={i}
className="flex items-center gap-2 text-xs cursor-pointer"
onClick={() => setSelected(i)}
>
<div
className={`w-3.5 h-3.5 border border-border rounded-full flex items-center justify-center transition-colors ${selected === i ? "border-foreground" : ""}`}
>
{selected === i && (
<div className="w-2 h-2 rounded-full bg-foreground" />
)}
</div>
{opt}
</label>
))}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Rating({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const ratingValue = (props.value as number) || 0;
const maxRating = (props.max as number) || 5;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<div className="text-[10px] text-muted-foreground mb-1 text-left">
{props.label as string}
</div>
) : null}
<div className="flex gap-0.5">
{Array.from({ length: maxRating }).map((_, i) => (
<span
key={i}
className={`text-sm ${i < ratingValue ? "text-yellow-400" : "text-muted"}`}
>
*
</span>
))}
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import type { ComponentRenderProps } from "./types";
import {
baseClass,
getCustomClass,
getOpenSelect,
setOpenSelectValue,
getSelectValue,
setSelectValueForKey,
} from "./utils";
export function Select({ element }: ComponentRenderProps) {
const { props, key } = element;
const customClass = getCustomClass(props);
const options = (props.options as string[]) || [];
const selectedValue = getSelectValue(key);
const isOpen = getOpenSelect() === key;
return (
<div className={`relative ${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<div
onClick={() => setOpenSelectValue(isOpen ? null : key)}
className="h-7 w-full bg-background border border-border rounded px-2 text-xs flex items-center justify-between cursor-pointer hover:border-foreground/30 transition-colors"
>
<span
className={
selectedValue ? "text-foreground" : "text-muted-foreground/50"
}
>
{selectedValue || (props.placeholder as string) || "Select..."}
</span>
<svg
className={`w-3 h-3 transition-transform ${isOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
{isOpen && options.length > 0 && (
<div className="absolute z-10 top-full left-0 right-0 mt-1 bg-background border border-border rounded shadow-lg overflow-hidden">
{options.map((opt, i) => (
<div
key={i}
onClick={() => {
setSelectValueForKey(key, opt);
setOpenSelectValue(null);
}}
className={`px-2 py-1.5 text-xs text-left cursor-pointer hover:bg-muted transition-colors ${selectedValue === opt ? "bg-muted" : ""}`}
>
{opt}
</div>
))}
</div>
)}
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Stack({ element, children }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const isHorizontal = props.direction === "horizontal";
const stackGap =
props.gap === "lg" ? "gap-3" : props.gap === "sm" ? "gap-1" : "gap-2";
return (
<div
className={`flex ${isHorizontal ? "flex-row flex-wrap items-center" : "flex-col"} ${stackGap} ${baseClass} ${customClass}`}
>
{children}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useState } from "react";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Switch({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const [checked, setChecked] = useState(!!props.checked);
return (
<label
className={`flex items-center justify-between gap-2 text-xs cursor-pointer ${baseClass} ${customClass}`}
onClick={() => setChecked((prev) => !prev)}
>
<span>{props.label as string}</span>
<div
className={`w-8 h-4 rounded-full relative transition-colors ${checked ? "bg-foreground" : "bg-border"}`}
>
<div
className={`absolute w-3 h-3 rounded-full bg-background top-0.5 transition-all ${checked ? "right-0.5" : "left-0.5"}`}
/>
</div>
</label>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Text({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const textVariant = props.variant as string;
const textClass =
textVariant === "caption"
? "text-[10px]"
: textVariant === "muted"
? "text-xs text-muted-foreground"
: "text-xs";
return (
<p className={`${textClass} text-left ${baseClass} ${customClass}`}>
{props.content as string}
</p>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import type { ComponentRenderProps } from "./types";
import { baseClass, getCustomClass } from "./utils";
export function Textarea({ element }: ComponentRenderProps) {
const { props } = element;
const customClass = getCustomClass(props);
const rows = (props.rows as number) || 3;
return (
<div className={`${baseClass} ${customClass}`}>
{props.label ? (
<label className="text-[10px] text-muted-foreground block mb-0.5 text-left">
{props.label as string}
</label>
) : null}
<textarea
placeholder={(props.placeholder as string) || ""}
rows={rows}
className="w-full bg-background border border-border rounded px-2 py-1 text-xs resize-none focus:outline-none focus:ring-1 focus:ring-foreground/20"
/>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import type { UIElement, Action } from "@json-render/core";
export interface ComponentRenderProps {
element: UIElement;
children?: ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}
export type ComponentRegistry = Record<
string,
React.ComponentType<ComponentRenderProps>
>;
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
// Shared animation class
export const baseClass =
"animate-in fade-in slide-in-from-bottom-1 duration-200";
// Helper to get custom classes
export function getCustomClass(props: Record<string, unknown>): string {
return Array.isArray(props.className)
? (props.className as string[]).join(" ")
: "";
}
// State for interactive components
let openSelect: string | null = null;
let setOpenSelect: (v: string | null) => void = () => {};
let selectValues: Record<string, string> = {};
let setSelectValues: (
fn: (prev: Record<string, string>) => Record<string, string>,
) => void = () => {};
export function useInteractiveState() {
const [_openSelect, _setOpenSelect] = useState<string | null>(null);
const [_selectValues, _setSelectValues] = useState<Record<string, string>>(
{},
);
openSelect = _openSelect;
setOpenSelect = _setOpenSelect;
selectValues = _selectValues;
setSelectValues = _setSelectValues;
return { openSelect, selectValues };
}
export function getOpenSelect() {
return openSelect;
}
export function setOpenSelectValue(v: string | null) {
setOpenSelect(v);
}
export function getSelectValue(key: string) {
return selectValues[key];
}
export function setSelectValueForKey(key: string, value: string) {
setSelectValues((prev) => ({ ...prev, [key]: value }));
}
-597
View File
@@ -1,597 +0,0 @@
"use client";
import {
useRef,
useEffect,
useState,
useCallback,
type PointerEvent as ReactPointerEvent,
} from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import Link from "next/link";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const DESKTOP_DEFAULT_WIDTH = 400;
const DESKTOP_MIN_WIDTH = 300;
const DESKTOP_MAX_WIDTH = 700;
function setCookie(name: string, value: string) {
document.cookie = `${name}=${encodeURIComponent(value)};path=/;max-age=${60 * 60 * 24 * 365};samesite=lax`;
}
const TOOL_LABELS: Record<
string,
{ label: string; pastLabel: string; argKey?: string }
> = {
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
};
function isToolPart(part: { type: string }): part is {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}
function getToolName(part: { type: string; toolName?: string }): string {
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
return part.type.replace(/^tool-/, "");
}
function ToolCallDisplay({
part,
}: {
part: {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
}) {
const toolName = getToolName(part);
const config = TOOL_LABELS[toolName] ?? {
label: toolName,
pastLabel: toolName,
};
const isDone = part.state === "output-available";
const isError = part.state === "output-error";
const isRunning = !isDone && !isError;
const displayLabel = isRunning ? config.label : config.pastLabel;
const args = (part.input ?? {}) as Record<string, unknown>;
const argValue = config.argKey ? args[config.argKey] : undefined;
const argPreview =
argValue != null
? String(argValue)
.replace(/\/workspace\//g, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "")
: "";
// Link to the docs page if it's a /docs/ path from readFile
const docsLink =
toolName === "readFile" &&
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
? argPreview
: null;
const argEl = argPreview ? (
docsLink ? (
<Link href={docsLink} className="truncate underline underline-offset-2">
{argPreview}
</Link>
) : (
<span className="truncate">{argPreview}</span>
)
) : null;
return (
<div className="text-xs py-0.5 min-w-0">
{isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
</span>
) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0 max-w-full">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
</span>
)}
</div>
);
}
const SUGGESTIONS = [
"What is json-render?",
"How do I install it?",
"How does streaming work?",
"What components are available?",
"How do I create a custom schema?",
];
export function DocsChat({
defaultOpen = false,
defaultWidth = DESKTOP_DEFAULT_WIDTH,
}: {
defaultOpen?: boolean;
defaultWidth?: number;
}) {
const [open, setOpen] = useState(defaultOpen);
const [input, setInput] = useState("");
const [isDesktop, setIsDesktop] = useState(false);
const [hasMounted, setHasMounted] = useState(false);
const [desktopWidth, setDesktopWidth] = useState(
Math.min(DESKTOP_MAX_WIDTH, Math.max(DESKTOP_MIN_WIDTH, defaultWidth)),
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const launcherRef = useRef<HTMLButtonElement>(null);
const restoredRef = useRef(false);
const isDraggingRef = useRef(false);
const { messages, sendMessage, status, setMessages, error } = useChat({
transport,
});
const isLoading = status === "streaming" || status === "submitted";
const showMessages = messages.length > 0 || !!error || isLoading;
// Detect desktop vs mobile. Close sidebar on mobile if it was open from cookie.
useEffect(() => {
const mq = window.matchMedia("(min-width: 640px)");
setIsDesktop(mq.matches);
setHasMounted(true);
// If on mobile but sidebar was open from cookie, close it
if (!mq.matches && defaultOpen) {
setOpen(false);
}
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Persist open state to cookie (only after mount to avoid overwriting on mobile)
useEffect(() => {
if (hasMounted) {
setCookie("docs-chat-open", String(open));
}
}, [open, hasMounted]);
useEffect(() => {
const launcher = launcherRef.current;
if (!hasMounted || open || !launcher) return;
const footer = document.querySelector("footer");
let frame = 0;
const update = () => {
frame = 0;
const rect = document
.querySelector("footer fieldset")
?.getBoundingClientRect();
const overlap =
rect && rect.width > 0 && rect.bottom > 0
? Math.max(0, innerHeight - rect.top)
: 0;
launcher.style.setProperty("--chat-launcher-bottom", `${24 + overlap}px`);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
const resize = new ResizeObserver(schedule);
resize.observe(document.body);
const mutation = new MutationObserver(schedule);
if (footer) {
resize.observe(footer);
mutation.observe(footer, { childList: true, subtree: true });
}
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
update();
return () => {
cancelAnimationFrame(frame);
resize.disconnect();
mutation.disconnect();
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
};
}, [hasMounted, open]);
// Push page content on desktop when pane is open.
// Use padding on body so the page scrollbar stays at the viewport edge (behind the sidebar)
// instead of appearing right next to the sidebar's scrollbar.
useEffect(() => {
const body = document.body;
if (isDesktop && open) {
body.style.paddingRight = `min(${desktopWidth}px, calc(100vw - 320px))`;
if (!isDraggingRef.current) {
body.style.transition = "padding-right 150ms ease";
}
} else if (isDesktop) {
body.style.paddingRight = "0px";
body.style.transition = "padding-right 150ms ease";
}
return () => {
body.style.paddingRight = "0px";
body.style.transition = "";
};
}, [isDesktop, open, desktopWidth]);
// Resize handle drag
const handleResizePointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
isDraggingRef.current = true;
document.documentElement.style.transition = "none";
const startX = e.clientX;
const startWidth = desktopWidth;
const onPointerMove = (ev: globalThis.PointerEvent) => {
const delta = startX - ev.clientX;
const newWidth = Math.min(
DESKTOP_MAX_WIDTH,
Math.max(DESKTOP_MIN_WIDTH, startWidth + delta),
);
setDesktopWidth(newWidth);
};
const onPointerUp = () => {
isDraggingRef.current = false;
document.documentElement.style.transition = "";
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
};
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
},
[desktopWidth],
);
// Persist width to cookie
useEffect(() => {
setCookie("docs-chat-width", String(desktopWidth));
}, [desktopWidth]);
// Restore messages from sessionStorage on mount
useEffect(() => {
if (restoredRef.current) return;
restoredRef.current = true;
try {
const stored = sessionStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
}
} catch {
// ignore parse errors
}
}, [setMessages]);
// Save completed messages to sessionStorage
useEffect(() => {
if (!restoredRef.current) return;
if (isLoading) return;
if (messages.length === 0) {
sessionStorage.removeItem(STORAGE_KEY);
return;
}
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
} catch {
// ignore quota errors
}
}, [messages, isLoading]);
// Cmd+I to open sidebar and focus prompt, Escape to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => {
if (!prev) {
setTimeout(() => inputRef.current?.focus(), 200);
}
return !prev;
});
}
if (
e.key === "Escape" &&
open &&
(isDesktop ||
(e.target instanceof Element &&
e.target.closest("#json-render-chat-mobile")))
) {
setOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, isDesktop]);
// Auto-focus input when opened
useEffect(() => {
if (open) {
const timer = setTimeout(() => inputRef.current?.focus(), 200);
return () => clearTimeout(timer);
}
}, [open]);
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
// Scroll to bottom when messages change or error occurs
useEffect(() => {
const el = messagesScrollRef.current;
if (!el) return;
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}, [messages, error]);
const handleSubmit = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage({ text: input });
setInput("");
},
[input, isLoading, sendMessage],
);
const handleClear = useCallback(() => {
setMessages([]);
sessionStorage.removeItem(STORAGE_KEY);
}, [setMessages]);
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
);
};
// Shared chat panel content used by both desktop and mobile
const chatPanel = (
<>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b shrink-0">
<span className="text-sm font-medium">json-render Docs</span>
<div className="flex items-center gap-3">
{showMessages && (
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
)}
<button
onClick={() => setOpen(false)}
className="text-muted-foreground hover:text-foreground transition-colors"
aria-label="Close panel"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
{/* Content: suggestions or messages */}
{showMessages ? (
<div
ref={messagesScrollRef}
className="flex-1 min-h-0 p-4 space-y-4 overflow-y-auto"
>
{messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</div>
) : (
<div className="space-y-2">
{message.parts.map((part, i) => {
if (part.type === "text" && part.text) {
return (
<div
key={i}
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
>
<Streamdown>{part.text}</Streamdown>
</div>
);
}
if (isToolPart(part)) {
return (
<ToolCallDisplay key={part.toolCallId} part={part} />
);
}
return null;
})}
</div>
)}
</div>
);
})}
{error && (
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
{(() => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || parsed.error || error.message;
} catch {
return (
error.message || "Something went wrong. Please try again."
);
}
})()}
</div>
)}
</div>
) : (
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex flex-wrap gap-2 p-4">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onClick={() => {
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border bg-secondary font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
className="flex items-end gap-2 px-4 py-3 border-t shrink-0"
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
enterKeyHint="send"
placeholder="Ask a question..."
aria-label="Ask a question"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed placeholder:text-muted-foreground"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-primary text-primary-foreground rounded-full p-1.5 hover:bg-primary/90 transition-colors disabled:opacity-30 shrink-0"
aria-label="Send message"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</>
);
return (
<>
{/* Ask AI trigger button */}
{!open && (
<button
ref={launcherRef}
data-docs-chat-launcher
onClick={() => setOpen(true)}
className="fixed z-30 bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 -translate-x-1/2 min-[640px]:left-auto min-[640px]:translate-x-0 min-[640px]:right-6 min-[640px]:bottom-[var(--chat-launcher-bottom,24px)] flex h-10 items-center gap-2 px-4 py-2 rounded-lg border border-primary bg-primary text-primary-foreground shadow-lg hover:bg-primary/90 transition-colors text-sm font-medium"
aria-label="Ask AI"
aria-expanded={open}
aria-controls={
isDesktop ? "json-render-chat-desktop" : "json-render-chat-mobile"
}
aria-keyshortcuts="Meta+I Control+I"
>
Ask AI
<kbd className="hidden min-[640px]:inline-flex items-center gap-0.5 text-xs opacity-60 font-mono">
<span>&#8984;</span>I
</kbd>
</button>
)}
{/* Desktop: resizable side pane — always rendered, hidden on mobile via CSS */}
<aside
id="json-render-chat-desktop"
inert={!open || !isDesktop}
className={`hidden sm:flex fixed top-0 right-0 bottom-0 z-40 border-l bg-background transition-transform duration-150 ease-in-out ${open ? "translate-x-0" : "translate-x-full"}`}
style={{ width: `min(${desktopWidth}px, calc(100vw - 320px))` }}
aria-hidden={!open || !isDesktop}
>
{/* Resize handle */}
<div
onPointerDown={handleResizePointerDown}
className="absolute top-0 bottom-0 left-0 w-1.5 cursor-col-resize hover:bg-ring/30 active:bg-ring/50 transition-colors z-10"
/>
<div className="flex flex-col flex-1 min-w-0">{chatPanel}</div>
</aside>
{/* Mobile: Sheet overlay/drawer — only after mount to avoid flash on desktop */}
{hasMounted && !isDesktop && (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
id="json-render-chat-mobile"
aria-describedby={undefined}
side="right"
overlayClassName="!bg-background"
className="!inset-0 !w-full !h-full !max-w-none p-0 flex flex-col"
style={{ backgroundColor: "var(--background)", opacity: 1 }}
>
<SheetTitle className="sr-only">AI Chat</SheetTitle>
{chatPanel}
</SheetContent>
</Sheet>
)}
</>
);
}
+57 -41
View File
@@ -10,15 +10,53 @@ import {
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import { docsNavigation, allDocsPages } from "@/lib/docs-navigation";
const navigation = [
{
title: "Getting Started",
items: [
{ title: "Introduction", href: "/docs" },
{ title: "Installation", href: "/docs/installation" },
{ title: "Quick Start", href: "/docs/quick-start" },
],
},
{
title: "Core Concepts",
items: [
{ title: "Catalog", href: "/docs/catalog" },
{ title: "Components", href: "/docs/components" },
{ title: "Data Binding", href: "/docs/data-binding" },
{ title: "Actions", href: "/docs/actions" },
{ title: "Visibility", href: "/docs/visibility" },
{ title: "Validation", href: "/docs/validation" },
],
},
{
title: "Guides",
items: [
{ title: "AI SDK Integration", href: "/docs/ai-sdk" },
{ title: "Streaming", href: "/docs/streaming" },
],
},
{
title: "API Reference",
items: [
{ title: "@json-render/core", href: "/docs/api/core" },
{ title: "@json-render/react", href: "/docs/api/react" },
],
},
];
// Flatten all pages for current page lookup
const allPages = navigation.flatMap((section) => section.items);
export function DocsMobileNav() {
const [open, setOpen] = useState(false);
const pathname = usePathname();
const currentPage = useMemo(() => {
const page = allDocsPages.find((page) => page.href === pathname);
return page ?? allDocsPages[0];
const page = allPages.find((page) => page.href === pathname);
return page ?? allPages[0];
}, [pathname]);
return (
@@ -32,49 +70,27 @@ export function DocsMobileNav() {
<SheetContent className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Table of Contents</SheetTitle>
<nav className="space-y-6">
{docsNavigation.map((section) => (
{navigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => {
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground"
} ${isExternal ? "inline-flex items-center gap-1" : ""}`}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
onClick={() => setOpen(false)}
className={`text-sm block py-2 transition-colors ${
pathname === item.href
? "text-foreground font-medium"
: "text-muted-foreground hover:text-foreground"
}`}
>
{item.title}
</Link>
</li>
))}
</ul>
</div>
))}
-63
View File
@@ -1,63 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import { docsNavigation } from "@/lib/docs-navigation";
export function DocsSidebar() {
const pathname = usePathname();
return (
<nav className="space-y-6 pb-8">
{docsNavigation.map((section) => (
<div key={section.title}>
<h4 className="text-xs font-normal text-muted-foreground/50 uppercase tracking-wider mb-2">
{section.title}
</h4>
<ul className="space-y-1">
{section.items.map((item) => {
const isActive = pathname === item.href;
const isExternal = item.external;
return (
<li key={item.href}>
<Link
href={item.href}
{...(isExternal && {
target: "_blank",
rel: "noopener noreferrer",
})}
className={cn(
"text-sm transition-colors block py-1",
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
isExternal && "inline-flex items-center gap-1",
)}
>
{item.title}
{isExternal && (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
)}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}
-48
View File
@@ -1,48 +0,0 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ExpandableCodeProps {
children: React.ReactNode;
maxHeight?: number;
}
export function ExpandableCode({
children,
maxHeight = 300,
}: ExpandableCodeProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [needsExpansion, setNeedsExpansion] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setNeedsExpansion(contentRef.current.scrollHeight > maxHeight);
}
}, [maxHeight]);
return (
<div className="relative">
<div
ref={contentRef}
className="overflow-hidden transition-[max-height] duration-300"
style={{
maxHeight: isExpanded || !needsExpansion ? "none" : maxHeight,
}}
>
{children}
</div>
{needsExpansion && !isExpanded && (
<>
<div className="absolute bottom-0 left-0 right-0 h-24 bg-gradient-to-t from-neutral-100 dark:from-[#0a0a0a] to-transparent pointer-events-none" />
<button
onClick={() => setIsExpanded(true)}
className="absolute bottom-3 left-1/2 -translate-x-1/2 px-3 py-1.5 text-xs font-medium text-muted-foreground bg-neutral-200 dark:bg-neutral-800 hover:bg-neutral-300 dark:hover:bg-neutral-700 rounded-md transition-colors"
>
Show all
</button>
</>
)}
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="border-t border-border">
<div className="max-w-5xl mx-auto px-6 py-6 flex justify-between items-center text-sm text-muted-foreground">
<div>json-render</div>
<div className="flex gap-6">
<Link
href="/docs"
className="hover:text-foreground transition-colors"
>
Docs
</Link>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="hover:text-foreground transition-colors"
>
GitHub
</a>
</div>
</div>
</footer>
);
}
@@ -1,13 +0,0 @@
"use client";
import { GeistdocsProvider } from "@vercel/geistdocs/layout";
import type { ReactNode } from "react";
import { config } from "@/lib/geistdocs/config";
export function DocsProvider({ children }: { children: ReactNode }) {
return (
<GeistdocsProvider config={config} lang="en">
{children}
</GeistdocsProvider>
);
}
@@ -1,272 +0,0 @@
"use client";
function ScheduleItem({
time,
label,
color,
}: {
time: string;
label: string;
color: string;
}) {
return (
<div className="flex items-center gap-2 py-1.5 border-t border-border/50 first:border-t-0">
<span className="text-[10px] text-muted-foreground/60 w-12 shrink-0 tabular-nums">
{time}
</span>
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ background: color }}
/>
<span className="text-[11px] text-muted-foreground">{label}</span>
</div>
);
}
function InlineModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-sm font-medium text-foreground text-center mb-3">
Inline Mode
</div>
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-col">
<div className="flex-1 p-4 space-y-3 overflow-hidden">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-3.5 py-2 max-w-[85%]">
<span className="text-[11px] text-foreground/80">
I have back-to-back meetings today
</span>
</div>
</div>
{/* AI response */}
<div className="space-y-2">
<p className="text-[11px] text-muted-foreground/70">
Packed day! Here&apos;s what you&apos;ve got:
</p>
<div className="bg-muted/40 border border-border rounded-xl p-3 w-[92%]">
<div className="text-[11px] font-semibold text-foreground/80 mb-2">
Today&apos;s Schedule
</div>
<ScheduleItem
time="10:00 AM"
label="Design Review"
color="#7aa2f7"
/>
<ScheduleItem
time="1:00 PM"
label="Sprint Planning"
color="#bb9af7"
/>
<ScheduleItem
time="3:30 PM"
label="Team Standup"
color="#9ece6a"
/>
<ScheduleItem
time="4:30 PM"
label="Eng All-Hands"
color="#e0af68"
/>
</div>
</div>
</div>
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
AI decides when UI beats text
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>AI chatbots</span>
<span className="text-muted-foreground/30">/</span>
<span>Copilots</span>
<span className="text-muted-foreground/30">/</span>
<span>Assistants</span>
</div>
</div>
</div>
);
}
function LandingPage() {
return (
<div className="w-full h-full flex flex-col bg-muted/20">
{/* Nav */}
<div className="flex items-center justify-between px-3 py-2 border-b border-border/50">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
className="shrink-0"
>
<rect
x="2"
y="14"
width="5"
height="8"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="9.5"
y="8"
width="5"
height="14"
rx="1"
className="fill-muted-foreground/60"
/>
<rect
x="17"
y="2"
width="5"
height="20"
rx="1"
className="fill-muted-foreground/60"
/>
</svg>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground/50">Pricing</span>
<span className="text-[9px] text-muted-foreground/50">Blog</span>
<span className="text-[8px] font-semibold bg-foreground text-background rounded-md px-1.5 py-0.5 whitespace-nowrap">
Get Started
</span>
</div>
</div>
{/* Hero */}
<div className="flex-1 flex flex-col items-center justify-center gap-2.5 text-center px-4">
<span className="text-[8px] font-medium text-green-400 bg-green-400/10 border border-green-400/15 rounded-full px-2 py-0.5">
Trusted by 2,000+ teams
</span>
<div className="text-sm font-bold text-foreground leading-tight">
Analytics that
<br />
move the needle
</div>
<div className="text-[10px] text-muted-foreground/50 leading-relaxed">
Ship what matters. No setup required.
</div>
<div className="flex gap-1.5 mt-1">
<button className="bg-foreground text-background text-[9px] font-semibold rounded-lg px-3 py-1.5 whitespace-nowrap">
Start Free
</button>
<button className="border border-border text-muted-foreground text-[9px] font-medium rounded-lg px-3 py-1.5 whitespace-nowrap">
Book Demo
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-center gap-3 py-2 border-t border-border/50">
<span className="text-[8px] text-muted-foreground/30">Privacy</span>
<span className="text-[8px] text-muted-foreground/30">Terms</span>
<span className="text-[8px] text-muted-foreground/30">Status</span>
</div>
</div>
);
}
function StandaloneModeDiagram() {
return (
<div className="flex flex-col h-full">
<div className="text-sm font-medium text-foreground text-center mb-3">
Standalone Mode
</div>
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-row">
{/* Left panel - conversation */}
<div className="w-[38%] border-r border-border/50 flex flex-col">
<div className="flex-1 p-3 space-y-2.5">
{/* User message */}
<div className="flex justify-end">
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-2.5 py-1.5">
<span className="text-[10px] text-foreground/80 block leading-relaxed">
A landing page for my analytics startup
</span>
</div>
</div>
{/* AI text */}
<p className="text-[10px] text-muted-foreground/60 leading-relaxed">
Here&apos;s a clean landing page with a hero, nav, and CTAs.
</p>
</div>
{/* Prompt input */}
<div className="p-2.5">
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground/40 flex-1">
Message...
</span>
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
<svg
className="w-2.5 h-2.5 text-muted-foreground/50"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"
transform="rotate(-90 12 12)"
/>
</svg>
</div>
</div>
</div>
</div>
{/* Right panel - landing page */}
<div className="flex-1">
<LandingPage />
</div>
</div>
<div className="mt-3 flex flex-col items-center gap-2">
<div className="text-xs font-medium text-muted-foreground">
Prompt in, UI out
</div>
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
<span>Website builders</span>
<span className="text-muted-foreground/30">/</span>
<span>Text-to-widget</span>
<span className="text-muted-foreground/30">/</span>
<span>Dashboards</span>
</div>
</div>
</div>
);
}
export function GenerationModesDiagram() {
return (
<div className="not-prose my-8">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="h-[420px]">
<InlineModeDiagram />
</div>
<div className="h-[420px]">
<StandaloneModeDiagram />
</div>
</div>
</div>
);
}
+23 -180
View File
@@ -1,190 +1,33 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ThemeToggle } from "./theme-toggle";
import { Search } from "./search";
import {
Sheet,
SheetTrigger,
SheetContent,
SheetTitle,
} from "@/components/ui/sheet";
import { cn } from "@/lib/utils";
const navLinks = [
{ href: "/playground", label: "Playground" },
{ href: "/examples", label: "Examples" },
{ href: "/docs", label: "Docs" },
];
function GitHubLink({
className,
stars,
}: {
className?: string;
stars?: string;
}) {
export function Header() {
return (
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className={cn(
"flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors",
className,
)}
>
<svg
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
{stars && <span>{stars}</span>}
</a>
);
}
export function Header({ stars }: { stars?: string }) {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
const isActive = (href: string) => {
if (href === "/playground") {
return pathname === "/playground";
}
if (href === "/examples") {
return pathname.startsWith("/examples");
}
if (href === "/docs") {
return pathname.startsWith("/docs");
}
return false;
};
return (
<header className="sticky top-0 z-50 bg-background">
<div className="flex h-14 items-center justify-between px-4 gap-6">
<div className="flex items-center gap-2">
<Link href="https://vercel.com" title="Made with love by Vercel">
<svg
data-testid="geist-icon"
height="18"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="18"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 1L16 15H0L8 1Z"
fill="currentColor"
></path>
</svg>
<header className="sticky top-0 z-50 backdrop-blur-sm border-b border-border bg-background/80">
<div className="max-w-5xl mx-auto px-6 h-14 flex justify-between items-center">
<Link
href="/"
className="font-semibold hover:opacity-70 transition-opacity"
>
json-render
</Link>
<nav className="flex gap-4 items-center text-sm">
<Link
href="/docs"
className="text-muted-foreground hover:text-foreground transition-colors"
>
Docs
</Link>
<span className="text-(--ds-gray-500)">
<svg
data-testid="geist-icon"
height="16"
strokeLinejoin="round"
viewBox="0 0 16 16"
width="16"
style={{ color: "currentcolor" }}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M4.01526 15.3939L4.3107 14.7046L10.3107 0.704556L10.6061 0.0151978L11.9849 0.606077L11.6894 1.29544L5.68942 15.2954L5.39398 15.9848L4.01526 15.3939Z"
fill="currentColor"
></path>
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
json-render
</span>
</Link>
</div>
{/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-4">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className={cn(
"text-sm transition-colors",
isActive(link.href)
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{link.label}
</Link>
))}
<Search />
<GitHubLink stars={stars} />
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground transition-colors"
>
GitHub
</a>
<ThemeToggle />
</nav>
{/* Mobile nav */}
<div className="flex sm:hidden items-center gap-3">
<Search />
<GitHubLink stars={stars} />
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
<SheetTrigger
className="flex items-center justify-center"
aria-label="Open menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-muted-foreground"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>
</SheetTrigger>
<SheetContent side="right" className="overflow-y-auto p-6">
<SheetTitle className="mb-6">Menu</SheetTitle>
<nav className="flex flex-col gap-1">
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
onClick={() => setMobileOpen(false)}
className={cn(
"block py-2.5 text-sm transition-colors",
isActive(link.href)
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
>
{link.label}
</Link>
))}
<div className="my-3 border-t border-border" />
<div className="flex items-center justify-between py-2.5">
<span className="text-sm text-muted-foreground">Theme</span>
<ThemeToggle />
</div>
</nav>
</SheetContent>
</Sheet>
</div>
</div>
</header>
);
File diff suppressed because it is too large Load Diff
-266
View File
@@ -1,266 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
type SearchResult = {
title: string;
href: string;
section: string;
snippet: string;
};
export function Search() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
const navigate = useCallback(
(href: string) => {
setOpen(false);
setQuery("");
setResults([]);
router.push(href);
},
[router],
);
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 0);
} else {
setQuery("");
setResults([]);
}
}, [open]);
useEffect(() => {
const q = query.trim();
if (!q) {
setResults([]);
setLoading(false);
return;
}
setLoading(true);
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
const timeout = setTimeout(async () => {
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal,
});
if (res.ok) {
const data = await res.json();
setResults(data.results);
}
} catch {
// aborted or network error
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}, 150);
return () => {
clearTimeout(timeout);
controller.abort();
};
}, [query]);
useEffect(() => {
setActiveIndex(0);
}, [results]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, results.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter" && results[activeIndex]) {
e.preventDefault();
navigate(results[activeIndex].href);
}
}
useEffect(() => {
const active = listRef.current?.querySelector("[data-active='true']");
active?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
const hasQuery = query.trim().length > 0;
return (
<>
<button
onClick={() => setOpen(true)}
className="hidden sm:flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:border-foreground/25 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
Search docs
<kbd className="pointer-events-none ml-1 inline-flex items-center gap-0.5 rounded border border-border bg-background px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<span>&#8984;</span>K
</kbd>
</button>
<button
onClick={() => setOpen(true)}
className="sm:hidden flex items-center text-muted-foreground hover:text-foreground transition-colors"
aria-label="Search docs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
showCloseButton={false}
className="gap-0 p-0 sm:max-w-lg"
>
<DialogTitle className="sr-only">Search documentation</DialogTitle>
<div className="flex items-center gap-2 border-b px-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 text-muted-foreground"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search docs..."
className="flex-1 bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
/>
{query && (
<button
onClick={() => setQuery("")}
className="text-muted-foreground hover:text-foreground"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</button>
)}
</div>
<div
ref={listRef}
className="max-h-[min(60vh,400px)] overflow-y-auto p-2"
>
{loading && hasQuery ? (
<div className="flex items-center justify-center py-6">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
</div>
) : hasQuery && results.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No results found.
</p>
) : !hasQuery ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Type to search documentation...
</p>
) : (
results.map((item, i) => (
<button
key={item.href}
data-active={i === activeIndex}
onClick={() => navigate(item.href)}
onMouseEnter={() => setActiveIndex(i)}
className={cn(
"flex w-full flex-col gap-1 rounded-md px-3 py-2 text-left transition-colors",
i === activeIndex
? "bg-accent text-accent-foreground"
: "text-foreground",
)}
>
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{item.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
{item.section}
</span>
</div>
{item.snippet && (
<span className="line-clamp-2 text-xs text-muted-foreground leading-relaxed">
{item.snippet}
</span>
)}
</button>
))
)}
</div>
</DialogContent>
</Dialog>
</>
);
}
-83
View File
@@ -1,83 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
type Heading = {
id: string;
text: string;
level: number;
};
function getHeadings(): Heading[] {
const article = document.querySelector("article");
if (!article) return [];
const elements = article.querySelectorAll("h2[id], h3[id]");
return Array.from(elements).map((el) => ({
id: el.id,
text: el.textContent?.replace(/#$/, "").trim() ?? "",
level: el.tagName === "H3" ? 3 : 2,
}));
}
export function TableOfContents() {
const pathname = usePathname();
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>("");
useEffect(() => {
const timer = setTimeout(() => setHeadings(getHeadings()), 100);
return () => clearTimeout(timer);
}, [pathname]);
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: "0px 0px -75% 0px", threshold: 0.1 },
);
for (const h of headings) {
const el = document.getElementById(h.id);
if (el) observer.observe(el);
}
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<nav aria-label="On this page">
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
On this page
</h4>
<ul className="space-y-1">
{headings.map((h) => (
<li key={h.id}>
<a
href={`#${h.id}`}
className={cn(
"block text-xs leading-relaxed py-0.5 transition-colors",
h.level === 3 && "pl-3",
activeId === h.id
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{h.text}
</a>
</li>
))}
</ul>
</nav>
);
}
-1
View File
@@ -6,7 +6,6 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return (
<NextThemesProvider
attribute="class"
value={{ dark: "dark-theme", light: "light-theme" }}
defaultTheme="dark"
enableSystem
disableTransitionOnChange
-66
View File
@@ -1,66 +0,0 @@
"use client";
import * as React from "react";
import { ChevronDownIcon } from "lucide-react";
import { Accordion as AccordionPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
-66
View File
@@ -1,66 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className,
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };
+2 -4
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
@@ -22,11 +22,9 @@ const buttonVariants = cva(
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
@@ -48,7 +46,7 @@ function Button({
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
const Comp = asChild ? Slot : "button";
return (
<Comp
-241
View File
@@ -1,241 +0,0 @@
"use client";
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};
-32
View File
@@ -1,32 +0,0 @@
"use client";
import * as React from "react";
import { CheckIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

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