Compare commits

..
Author SHA1 Message Date
Chris Tate 50e775a165 fix deps 2026-02-06 14:31:45 -06:00
1549 changed files with 12385 additions and 203367 deletions
+13
View File
@@ -0,0 +1,13 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
## Adding a changeset
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
packages to the same version.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@json-render/core",
"@json-render/react",
"@json-render/remotion",
"@json-render/codegen"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
}
}
+8
View File
@@ -0,0 +1,8 @@
---
"@json-render/codegen": patch
"@json-render/core": patch
"@json-render/react": patch
"@json-render/remotion": patch
---
add defineRegistry function
-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
+15 -134
View File
@@ -6,74 +6,21 @@ on:
- main
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: read
contents: write
pull-requests: write
jobs:
check-release:
name: Check for new version
release:
name: Release
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
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
@@ -81,86 +28,20 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: .node-version
node-version: 20
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
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
+6 -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,4 @@ 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
.env*.local
-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"]
}
}
}
+1 -77
View File
@@ -25,88 +25,12 @@ This ensures we don't install outdated versions that may have incompatible types
## Code Style
- Do not use emojis in code or UI
- Do not use barrel files (index.ts that re-exports from other files)
- 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
+48 -640
View File
@@ -1,52 +1,22 @@
# 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 videos 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
@@ -54,7 +24,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
```typescript
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { schema } from "@json-render/react";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -105,8 +75,10 @@ const { registry } = defineRegistry(catalog, {
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, emit }) => (
<button onClick={() => emit("press")}>{props.label}</button>
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
@@ -126,37 +98,11 @@ 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 |
| Package | Description |
|---------|-------------|
| `@json-render/core` | Schemas, catalogs, AI prompts, SpecStream utilities |
| `@json-render/react` | React renderer, contexts, hooks |
| `@json-render/remotion` | Remotion video renderer, timeline schema |
## Renderers
@@ -164,172 +110,38 @@ function Dashboard({ spec }) {
```tsx
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react/schema";
import { schema } from "@json-render/react";
// Flat spec format (root key + elements map)
// Element tree spec format
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: [],
},
},
root: {
type: "Card",
props: { title: "Hello" },
children: [
{ type: "Button", props: { label: "Click me" } }
]
}
};
// 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} />;
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
```tsx
import { Player } from "@remotion/player";
import {
Renderer,
schema,
standardComponentDefinitions,
} from "@json-render/remotion";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
// Timeline spec format
const spec = {
composition: {
id: "video",
fps: 30,
width: 1920,
height: 1080,
durationInFrames: 300,
},
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,
},
{ id: "clip-1", trackId: "main", component: "TitleCard", props: { title: "Hello" }, from: 0, durationInFrames: 90 }
],
audio: { tracks: [] },
audio: { tracks: [] }
};
<Player
@@ -339,348 +151,7 @@ const spec = {
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
@@ -717,81 +188,27 @@ const systemPrompt = catalog.prompt();
{
"type": "Alert",
"props": { "message": "Error occurred" },
"visible": [
{ "$state": "/form/hasError" },
{ "$state": "/form/errorDismissed", "not": true }
]
}
```
### Dynamic Props
Any prop value can be data-driven using expressions:
```json
{
"type": "Icon",
"props": {
"name": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "home",
"$else": "home-outline"
},
"color": {
"$cond": { "$state": "/activeTab", "eq": "home" },
"$then": "#007AFF",
"$else": "#8E8E93"
}
"visible": {
"and": [
{ "path": "/form/hasError" },
{ "not": { "path": "/form/errorDismissed" } }
]
}
}
```
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:
### Data Binding
```json
{
"type": "Pressable",
"type": "Metric",
"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": "Revenue",
"value": "{{data.revenue}}"
}
}
```
`watch` is a top-level field on elements (sibling of `type`/`props`/`children`). Watchers fire when the watched value changes, not on initial render.
---
## Demo
@@ -803,18 +220,9 @@ 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
- http://localhost:3002 — Remotion Video Example
## How It Works
@@ -823,16 +231,16 @@ flowchart LR
A[User Prompt] --> B[AI + Catalog]
B --> C[JSON Spec]
C --> D[Renderer]
B -.- E([guardrailed])
C -.- F([predictable])
D -.- G([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
-8
View File
@@ -3,10 +3,6 @@
# 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
@@ -16,7 +12,3 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# 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
-1
View File
@@ -11,7 +11,6 @@
# next.js
/.next/
/.source/
/out/
# production
-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.
@@ -1,25 +1,49 @@
---
title: "A2UI Integration"
---
import Link from "next/link";
import { Code } from "@/components/code";
Use `@json-render/core` to support [A2UI](https://a2ui.org) natively.
export const metadata = {
title: "A2UI Integration | json-render",
};
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can support A2UI. The examples are illustrative and may require adaptation for production use.
</p>
</div>
export default function A2UIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">A2UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use <code className="text-foreground">@json-render/core</code> to
support{" "}
<a
href="https://a2ui.org"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
A2UI
</a>{" "}
natively.
</p>
## Native A2UI Support
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support A2UI. The examples are illustrative and may require adaptation
for production use.
</p>
</div>
`@json-render/core` is schema-agnostic. Define a catalog that matches A2UI's format and build a renderer that understands it - no conversion layer needed.
<h2 className="text-xl font-semibold mt-12 mb-4">Native A2UI Support</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. Define a catalog that matches A2UI&apos;s format and
build a renderer that understands it - no conversion layer needed.
</p>
## Example A2UI Message
A2UI uses an adjacency list model - a flat list of components with ID references. This makes it easy to patch individual components:
```json
{
<h2 className="text-xl font-semibold mt-12 mb-4">Example A2UI Message</h2>
<p className="text-sm text-muted-foreground mb-4">
A2UI uses an adjacency list model - a flat list of components with ID
references. This makes it easy to patch individual components:
</p>
<Code lang="json">{`{
"surfaceUpdate": {
"surfaceId": "main",
"components": [
@@ -59,14 +83,12 @@ A2UI uses an adjacency list model - a flat list of components with ID references
}
]
}
}
```
}`}</Code>
## Define the A2UI Catalog
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Catalog
</h2>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// A2UI BoundValue schema
@@ -84,7 +106,7 @@ const Children = z.object({
}).optional(),
}).refine(d => d.explicitList || d.template);
export const a2uiCatalog = defineCatalog(schema, {
export const a2uiCatalog = createCatalog({
components: {
Text: {
description: 'Displays text content',
@@ -129,15 +151,15 @@ export const a2uiCatalog = defineCatalog(schema, {
},
// Add more A2UI standard components...
},
});
```
});`}</Code>
## Define the A2UI Schema
Define the schema for A2UI message types:
```typescript
import { z } from 'zod';
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the A2UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define the schema for A2UI message types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Component instance in the adjacency list
const A2UIComponent = z.object({
@@ -151,8 +173,8 @@ const SurfaceUpdate = z.object({
components: z.array(A2UIComponent),
});
// State model update message
const StateModelUpdate = z.object({
// Data model update message
const DataModelUpdate = z.object({
surfaceId: z.string().optional(),
path: z.string().optional(),
contents: z.array(z.object({
@@ -174,18 +196,18 @@ const BeginRendering = z.object({
// Complete A2UI message schema
export const A2UIMessage = z.object({
surfaceUpdate: SurfaceUpdate.optional(),
dataModelUpdate: StateModelUpdate.optional(),
dataModelUpdate: DataModelUpdate.optional(),
beginRendering: BeginRendering.optional(),
deleteSurface: z.object({ surfaceId: z.string() }).optional(),
});
```
});`}</Code>
## Build an A2UI Renderer
Create a renderer that processes the A2UI adjacency list format:
```tsx
import { a2uiCatalog } from './catalog';
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an A2UI Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes the A2UI adjacency list format:
</p>
<Code lang="tsx">{`import { a2uiCatalog } from './catalog';
// Component registry
const components = {
@@ -217,7 +239,7 @@ export function renderA2UI(
if (!bound) return undefined;
if (bound.literalString) return bound.literalString;
if (bound.path) {
const parts = bound.path.replace(/^\//, '').split('/');
const parts = bound.path.replace(/^\\//, '').split('/');
let value = dataModel;
for (const p of parts) value = value?.[p];
return value;
@@ -250,13 +272,10 @@ export function renderA2UI(
}
return render(rootId);
}
```
}`}</Code>
## Usage
```tsx
const [components] = useState(() => new Map());
<h2 className="text-xl font-semibold mt-12 mb-4">Usage</h2>
<Code lang="tsx">{`const [components] = useState(() => new Map());
const [dataModel, setDataModel] = useState({});
const [rootId, setRootId] = useState<string | null>(null);
@@ -276,9 +295,19 @@ function handleMessage(msg: any) {
}
// Render
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}
```
{rootId && renderA2UI(components, dataModel, rootId, handleAction)}`}</Code>
## Next
Learn about [Adaptive Cards integration](/docs/adaptive-cards) for another UI protocol.
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards integration
</Link>{" "}
for another UI protocol.
</p>
</article>
);
}
@@ -0,0 +1,791 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Adaptive Cards Integration | json-render",
};
export default function AdaptiveCardsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Adaptive Cards Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to render{" "}
<a
href="https://adaptivecards.io"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
Microsoft Adaptive Cards
</a>{" "}
natively.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support Adaptive Cards. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">
Adaptive Cards Overview
</h2>
<p className="text-sm text-muted-foreground mb-4">
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets.
Cards have a <code className="text-foreground">body</code> array of
elements and an optional{" "}
<code className="text-foreground">actions</code> array for interactive
buttons.
</p>
<h3 className="text-lg font-medium mt-8 mb-3">Example Adaptive Card</h3>
<Code lang="json">{`{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Hello, Adaptive Cards!",
"size": "large",
"weight": "bolder"
},
{
"type": "Image",
"url": "https://example.com/image.png",
"altText": "Example image"
},
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": "This is inside a container",
"wrap": true
}
]
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "auto",
"items": [
{ "type": "TextBlock", "text": "Column 1" }
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{ "type": "TextBlock", "text": "Column 2" }
]
}
]
},
{
"type": "Input.Text",
"id": "userInput",
"placeholder": "Enter your name",
"label": "Name"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
},
{
"type": "Action.OpenUrl",
"title": "Learn More",
"url": "https://adaptivecards.io"
}
]
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Creating an Adaptive Cards Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog matching the Adaptive Cards element types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
// Common Adaptive Cards properties
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
const ImageStyle = z.enum(['default', 'person']);
// Base element properties shared by most elements
const BaseElement = {
id: z.string().optional(),
isVisible: z.boolean().optional(),
separator: z.boolean().optional(),
spacing: Spacing.optional(),
};
export const adaptiveCardsCatalog = createCatalog({
components: {
// Root card
AdaptiveCard: {
description: 'Root Adaptive Card container',
props: z.object({
version: z.string(),
body: z.array(z.unknown()).optional(),
actions: z.array(z.unknown()).optional(),
fallbackText: z.string().optional(),
minHeight: z.string().optional(),
rtl: z.boolean().optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
// Elements
TextBlock: {
description: 'Displays text with formatting options',
props: z.object({
...BaseElement,
text: z.string(),
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
fontType: z.enum(['default', 'monospace']).optional(),
horizontalAlignment: HorizontalAlignment.optional(),
isSubtle: z.boolean().optional(),
maxLines: z.number().optional(),
size: FontSize.optional(),
weight: FontWeight.optional(),
wrap: z.boolean().optional(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
...BaseElement,
url: z.string(),
altText: z.string().optional(),
backgroundColor: z.string().optional(),
height: z.string().optional(),
width: z.string().optional(),
horizontalAlignment: HorizontalAlignment.optional(),
size: ImageSize.optional(),
style: ImageStyle.optional(),
}),
},
Container: {
description: 'Groups elements together',
props: z.object({
...BaseElement,
items: z.array(z.unknown()),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
bleed: z.boolean().optional(),
minHeight: z.string().optional(),
}),
},
ColumnSet: {
description: 'Arranges columns horizontally',
props: z.object({
...BaseElement,
columns: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
minHeight: z.string().optional(),
}),
},
Column: {
description: 'A column within a ColumnSet',
props: z.object({
...BaseElement,
items: z.array(z.unknown()).optional(),
width: z.union([z.string(), z.number()]).optional(),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
FactSet: {
description: 'Displays a series of facts as key/value pairs',
props: z.object({
...BaseElement,
facts: z.array(z.object({
title: z.string(),
value: z.string(),
})),
}),
},
ImageSet: {
description: 'Displays a collection of images',
props: z.object({
...BaseElement,
images: z.array(z.object({
type: z.literal('Image'),
url: z.string(),
altText: z.string().optional(),
})),
imageSize: ImageSize.optional(),
}),
},
ActionSet: {
description: 'Displays a set of actions',
props: z.object({
...BaseElement,
actions: z.array(z.unknown()),
}),
},
RichTextBlock: {
description: 'Rich text with inline formatting',
props: z.object({
...BaseElement,
inlines: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
}),
},
// Inputs
'Input.Text': {
description: 'Text input field',
props: z.object({
...BaseElement,
id: z.string(),
isMultiline: z.boolean().optional(),
maxLength: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Number': {
description: 'Number input field',
props: z.object({
...BaseElement,
id: z.string(),
max: z.number().optional(),
min: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.number().optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Date': {
description: 'Date picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Time': {
description: 'Time picker input',
props: z.object({
...BaseElement,
id: z.string(),
max: z.string().optional(),
min: z.string().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.Toggle': {
description: 'Toggle/checkbox input',
props: z.object({
...BaseElement,
id: z.string(),
title: z.string(),
label: z.string().optional(),
value: z.string().optional(),
valueOff: z.string().optional(),
valueOn: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.ChoiceSet': {
description: 'Dropdown or radio/checkbox group',
props: z.object({
...BaseElement,
id: z.string(),
choices: z.array(z.object({
title: z.string(),
value: z.string(),
})),
isMultiSelect: z.boolean().optional(),
style: z.enum(['compact', 'expanded']).optional(),
label: z.string().optional(),
value: z.string().optional(),
placeholder: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
// Actions
'Action.OpenUrl': {
description: 'Opens a URL',
props: z.object({
title: z.string().optional(),
url: z.string(),
iconUrl: z.string().optional(),
}),
},
'Action.Submit': {
description: 'Submits input data',
props: z.object({
title: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
'Action.ShowCard': {
description: 'Shows a card inline',
props: z.object({
title: z.string().optional(),
card: z.unknown(),
iconUrl: z.string().optional(),
}),
},
'Action.ToggleVisibility': {
description: 'Toggles visibility of elements',
props: z.object({
title: z.string().optional(),
targetElements: z.array(z.union([
z.string(),
z.object({ elementId: z.string(), isVisible: z.boolean().optional() }),
])),
iconUrl: z.string().optional(),
}),
},
'Action.Execute': {
description: 'Universal action for bots',
props: z.object({
title: z.string().optional(),
verb: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Building an Adaptive Cards Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that processes Adaptive Cards JSON:
</p>
<Code lang="tsx">{`'use client';
import React from 'react';
interface AdaptiveCardElement {
type: string;
[key: string]: unknown;
}
interface AdaptiveCard {
type: 'AdaptiveCard';
version: string;
body?: AdaptiveCardElement[];
actions?: AdaptiveCardElement[];
}
interface RenderContext {
onAction: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
inputs: Record<string, unknown>;
setInput: (id: string, value: unknown) => void;
}
// Widget registry for Adaptive Cards elements
const widgets: Record<string, React.FC<any>> = {
TextBlock: ({ text, size, weight, color, isSubtle, wrap, horizontalAlignment }) => {
const sizeClass = {
small: 'text-xs',
default: 'text-sm',
medium: 'text-base',
large: 'text-lg',
extraLarge: 'text-2xl',
}[size || 'default'];
const weightClass = {
lighter: 'font-light',
default: 'font-normal',
bolder: 'font-bold',
}[weight || 'default'];
const alignClass = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[horizontalAlignment || 'left'];
return (
<p className={\`\${sizeClass} \${weightClass} \${alignClass} \${isSubtle ? 'text-muted-foreground' : ''} \${wrap !== false ? '' : 'truncate'}\`}>
{text}
</p>
);
},
Image: ({ url, altText, size, style, horizontalAlignment }) => {
const sizeClass = {
auto: '',
stretch: 'w-full',
small: 'w-16',
medium: 'w-32',
large: 'w-48',
}[size || 'auto'];
return (
<div className={\`flex \${horizontalAlignment === 'center' ? 'justify-center' : horizontalAlignment === 'right' ? 'justify-end' : ''}\`}>
<img
src={url}
alt={altText || ''}
className={\`\${sizeClass} \${style === 'person' ? 'rounded-full' : ''}\`}
/>
</div>
);
},
Container: ({ items, style, children, ctx }) => {
const styleClass = {
default: '',
emphasis: 'bg-muted p-2 rounded',
good: 'bg-green-50 p-2 rounded',
attention: 'bg-red-50 p-2 rounded',
warning: 'bg-yellow-50 p-2 rounded',
accent: 'bg-blue-50 p-2 rounded',
}[style || 'default'];
return (
<div className={\`\${styleClass} space-y-2\`}>
{children || items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
ColumnSet: ({ columns, ctx }) => (
<div className="flex gap-2">
{columns?.map((col: any, i: number) => (
<AdaptiveElement key={i} element={{ ...col, type: 'Column' }} ctx={ctx} />
))}
</div>
),
Column: ({ items, width, style, ctx }) => {
const widthClass = width === 'auto' ? 'flex-none' :
width === 'stretch' ? 'flex-1' :
typeof width === 'number' ? \`flex-[\${width}]\` : 'flex-1';
return (
<div className={\`\${widthClass} space-y-2\`}>
{items?.map((item: any, i: number) => (
<AdaptiveElement key={i} element={item} ctx={ctx} />
))}
</div>
);
},
FactSet: ({ facts }) => (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{facts?.map((fact: any, i: number) => (
<React.Fragment key={i}>
<span className="font-medium">{fact.title}</span>
<span>{fact.value}</span>
</React.Fragment>
))}
</div>
),
ActionSet: ({ actions, ctx }) => (
<div className="flex gap-2 pt-2">
{actions?.map((action: any, i: number) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
),
'Input.Text': ({ id, placeholder, label, isMultiline, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{isMultiline ? (
<textarea
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
) : (
<input
type="text"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
defaultValue={value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
)}
</div>
),
'Input.Number': ({ id, placeholder, label, min, max, value, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
placeholder={placeholder}
min={min}
max={max}
defaultValue={value}
onChange={(e) => ctx.setInput(id, parseFloat(e.target.value))}
/>
</div>
),
'Input.Toggle': ({ id, title, label, valueOn = 'true', valueOff = 'false', value, ctx }) => (
<div className="flex items-center gap-2">
<input
type="checkbox"
id={id}
defaultChecked={value === valueOn}
onChange={(e) => ctx.setInput(id, e.target.checked ? valueOn : valueOff)}
/>
<label htmlFor={id} className="text-sm">{title || label}</label>
</div>
),
'Input.ChoiceSet': ({ id, choices, isMultiSelect, style, label, placeholder, ctx }) => (
<div className="space-y-1">
{label && <label className="text-sm font-medium">{label}</label>}
{style === 'expanded' ? (
<div className="space-y-1">
{choices?.map((choice: any, i: number) => (
<label key={i} className="flex items-center gap-2 text-sm">
<input
type={isMultiSelect ? 'checkbox' : 'radio'}
name={id}
value={choice.value}
onChange={(e) => ctx.setInput(id, e.target.value)}
/>
{choice.title}
</label>
))}
</div>
) : (
<select
className="w-full px-3 py-2 border rounded text-sm"
onChange={(e) => ctx.setInput(id, e.target.value)}
>
{placeholder && <option value="">{placeholder}</option>}
{choices?.map((choice: any, i: number) => (
<option key={i} value={choice.value}>{choice.title}</option>
))}
</select>
)}
</div>
),
'Action.Submit': ({ title, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Submit', data }, ctx.inputs)}
>
{title || 'Submit'}
</button>
),
'Action.OpenUrl': ({ title, url }) => (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 border rounded text-sm hover:bg-muted"
>
{title || 'Open'}
</a>
),
'Action.Execute': ({ title, verb, data, ctx }) => (
<button
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
onClick={() => ctx.onAction({ type: 'Action.Execute', verb, data }, ctx.inputs)}
>
{title || 'Execute'}
</button>
),
};
function AdaptiveElement({ element, ctx }: { element: AdaptiveCardElement; ctx: RenderContext }) {
const Widget = widgets[element.type];
if (!Widget) {
console.warn(\`Unknown Adaptive Card element: \${element.type}\`);
return null;
}
return <Widget {...element} ctx={ctx} />;
}
export function AdaptiveCardRenderer({
card,
onAction,
}: {
card: AdaptiveCard;
onAction?: (action: AdaptiveCardElement, data: Record<string, unknown>) => void;
}) {
const [inputs, setInputs] = React.useState<Record<string, unknown>>({});
const ctx: RenderContext = {
onAction: onAction || (() => {}),
inputs,
setInput: (id, value) => setInputs((prev) => ({ ...prev, [id]: value })),
};
return (
<div className="rounded-lg border p-4 space-y-3 max-w-md">
{card.body?.map((element, i) => (
<AdaptiveElement key={i} element={element} ctx={ctx} />
))}
{card.actions && card.actions.length > 0 && (
<div className="flex gap-2 pt-2 border-t">
{card.actions.map((action, i) => (
<AdaptiveElement key={i} element={action} ctx={ctx} />
))}
</div>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<p className="text-sm text-muted-foreground mb-4">
Render an Adaptive Card and handle actions:
</p>
<Code lang="tsx">{`'use client';
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
const card = {
type: 'AdaptiveCard' as const,
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Contact Form',
size: 'large',
weight: 'bolder',
},
{
type: 'Input.Text',
id: 'name',
label: 'Your Name',
placeholder: 'Enter your name',
},
{
type: 'Input.Text',
id: 'message',
label: 'Message',
placeholder: 'Enter your message',
isMultiline: true,
},
],
actions: [
{
type: 'Action.Submit',
title: 'Send',
data: { action: 'submitForm' },
},
],
};
export function ContactCard() {
const handleAction = (action: any, inputData: Record<string, unknown>) => {
console.log('Action:', action);
console.log('Input data:', inputData);
// Send to your backend
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: inputData }),
});
};
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Handling Action.Execute for Bots
</h2>
<p className="text-sm text-muted-foreground mb-4">
For bot scenarios, handle{" "}
<code className="text-foreground">Action.Execute</code> with the verb
and data:
</p>
<Code lang="typescript">{`interface ActionExecutePayload {
action: {
type: 'Action.Execute';
verb: string;
data?: unknown;
};
inputs: Record<string, unknown>;
}
async function handleBotAction(payload: ActionExecutePayload) {
const response = await fetch('/api/bot/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verb: payload.action.verb,
data: payload.action.data,
inputs: payload.inputs,
}),
});
// Bot may return a new card to render
const result = await response.json();
if (result.card) {
return result.card; // New AdaptiveCard to render
}
}`}</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/a2ui" className="text-foreground hover:underline">
A2UI integration
</Link>{" "}
for another agent-driven UI protocol.
</p>
</article>
);
}
+651
View File
@@ -0,0 +1,651 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "AG-UI Integration | json-render",
};
export default function AGUIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">AG-UI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to support{" "}
<a
href="https://docs.copilotkit.ai/ag-ui"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
AG-UI
</a>{" "}
(Agent User Interaction Protocol) from CopilotKit.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support AG-UI. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">What is AG-UI?</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI is an open protocol for connecting AI agents to user interfaces.
It provides a standardized way for agents to render UI components,
handle user input, and manage state. The protocol uses events streamed
over HTTP to update the UI in real-time.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">AG-UI Event Types</h2>
<p className="text-sm text-muted-foreground mb-4">
AG-UI defines several event types for agent-UI communication:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">TEXT_MESSAGE_START</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_CONTENT</code> /{" "}
<code className="text-foreground">TEXT_MESSAGE_END</code> — Streaming
text messages
</li>
<li>
<code className="text-foreground">TOOL_CALL_START</code> /{" "}
<code className="text-foreground">TOOL_CALL_ARGS</code> /{" "}
<code className="text-foreground">TOOL_CALL_END</code> — Tool/function
calls
</li>
<li>
<code className="text-foreground">STATE_SNAPSHOT</code> /{" "}
<code className="text-foreground">STATE_DELTA</code> — State updates
</li>
<li>
<code className="text-foreground">CUSTOM</code> — Custom events for UI
rendering
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">
Example AG-UI Event Stream
</h3>
<Code lang="json">{`{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\\"component\\": \\"Dashboard\\", \\"props\\": {\\"title\\": \\"Sales\\"}}"}
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
{"type": "RUN_FINISHED"}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define schemas for AG-UI event types:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Base event schema
const BaseEvent = z.object({
type: z.string(),
timestamp: z.number().optional(),
});
// Text message events
const TextMessageStart = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_START'),
messageId: z.string(),
role: z.enum(['user', 'assistant']),
});
const TextMessageContent = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_CONTENT'),
messageId: z.string(),
delta: z.string(),
});
const TextMessageEnd = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_END'),
messageId: z.string(),
});
// Tool call events
const ToolCallStart = BaseEvent.extend({
type: z.literal('TOOL_CALL_START'),
toolCallId: z.string(),
toolCallName: z.string(),
parentMessageId: z.string().optional(),
});
const ToolCallArgs = BaseEvent.extend({
type: z.literal('TOOL_CALL_ARGS'),
toolCallId: z.string(),
delta: z.string(),
});
const ToolCallEnd = BaseEvent.extend({
type: z.literal('TOOL_CALL_END'),
toolCallId: z.string(),
});
// State events
const StateSnapshot = BaseEvent.extend({
type: z.literal('STATE_SNAPSHOT'),
snapshot: z.record(z.unknown()),
});
const StateDelta = BaseEvent.extend({
type: z.literal('STATE_DELTA'),
delta: z.array(z.object({
op: z.enum(['add', 'remove', 'replace']),
path: z.string(),
value: z.unknown().optional(),
})),
});
// Custom event for UI components
const CustomEvent = BaseEvent.extend({
type: z.literal('CUSTOM'),
name: z.string(),
value: z.unknown(),
});
// Run lifecycle events
const RunStarted = BaseEvent.extend({
type: z.literal('RUN_STARTED'),
threadId: z.string(),
runId: z.string(),
});
const RunFinished = BaseEvent.extend({
type: z.literal('RUN_FINISHED'),
});
const RunError = BaseEvent.extend({
type: z.literal('RUN_ERROR'),
message: z.string(),
code: z.string().optional(),
});
// Union of all events
export const AGUIEvent = z.discriminatedUnion('type', [
TextMessageStart,
TextMessageContent,
TextMessageEnd,
ToolCallStart,
ToolCallArgs,
ToolCallEnd,
StateSnapshot,
StateDelta,
CustomEvent,
RunStarted,
RunFinished,
RunError,
]);
export type AGUIEvent = z.infer<typeof AGUIEvent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define the AG-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a catalog for UI components that agents can render:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const aguiCatalog = createCatalog({
components: {
// Layout components
Container: {
description: 'A container for grouping elements',
props: z.object({
direction: z.enum(['row', 'column']).optional(),
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
}),
},
Card: {
description: 'A card with optional title',
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Content components
Text: {
description: 'Text content',
props: z.object({
content: z.string(),
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
}),
},
Markdown: {
description: 'Renders markdown content',
props: z.object({
content: z.string(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
src: z.string(),
alt: z.string().optional(),
width: z.number().optional(),
height: z.number().optional(),
}),
},
// Data display
Table: {
description: 'Displays tabular data',
props: z.object({
columns: z.array(z.object({
key: z.string(),
header: z.string(),
width: z.string().optional(),
})),
data: z.array(z.record(z.unknown())),
}),
},
Chart: {
description: 'Renders a chart',
props: z.object({
type: z.enum(['line', 'bar', 'pie', 'area']),
data: z.array(z.record(z.unknown())),
xKey: z.string(),
yKey: z.string(),
title: z.string().optional(),
}),
},
Metric: {
description: 'Displays a metric value',
props: z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
change: z.number().optional(),
format: z.enum(['number', 'currency', 'percent']).optional(),
}),
},
// Input components
Button: {
description: 'Interactive button',
props: z.object({
label: z.string(),
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
disabled: z.boolean().optional(),
}),
},
Input: {
description: 'Text input field',
props: z.object({
name: z.string(),
label: z.string().optional(),
placeholder: z.string().optional(),
type: z.enum(['text', 'email', 'password', 'number']).optional(),
required: z.boolean().optional(),
}),
},
Select: {
description: 'Dropdown select',
props: z.object({
name: z.string(),
label: z.string().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string(),
})),
placeholder: z.string().optional(),
}),
},
Form: {
description: 'Form container',
props: z.object({
id: z.string(),
submitLabel: z.string().optional(),
}),
},
// Feedback components
Alert: {
description: 'Alert message',
props: z.object({
message: z.string(),
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
}),
},
Progress: {
description: 'Progress indicator',
props: z.object({
value: z.number(),
max: z.number().optional(),
label: z.string().optional(),
}),
},
Skeleton: {
description: 'Loading placeholder',
props: z.object({
width: z.string().optional(),
height: z.string().optional(),
variant: z.enum(['text', 'circular', 'rectangular']).optional(),
}),
},
},
actions: {
submit: {
description: 'Submit form data',
params: z.object({
formId: z.string(),
}),
},
navigate: {
description: 'Navigate to a URL',
params: z.object({
url: z.string(),
}),
},
callback: {
description: 'Trigger a callback to the agent',
params: z.object({
name: z.string(),
data: z.record(z.unknown()).optional(),
}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an AG-UI Event Processor
</h2>
<p className="text-sm text-muted-foreground mb-4">
Process AG-UI events and render UI components:
</p>
<Code lang="tsx">{`'use client';
import React, { useState, useCallback } from 'react';
import { AGUIEvent } from './schema';
interface AGUIState {
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
}>;
toolCalls: Map<string, {
name: string;
args: string;
result?: unknown;
}>;
state: Record<string, unknown>;
isRunning: boolean;
}
export function useAGUI() {
const [aguiState, setAGUIState] = useState<AGUIState>({
messages: [],
toolCalls: new Map(),
state: {},
isRunning: false,
});
const processEvent = useCallback((event: AGUIEvent) => {
switch (event.type) {
case 'RUN_STARTED':
setAGUIState(prev => ({ ...prev, isRunning: true }));
break;
case 'RUN_FINISHED':
setAGUIState(prev => ({ ...prev, isRunning: false }));
break;
case 'TEXT_MESSAGE_START':
setAGUIState(prev => ({
...prev,
messages: [...prev.messages, {
id: event.messageId,
role: event.role,
content: '',
}],
}));
break;
case 'TEXT_MESSAGE_CONTENT':
setAGUIState(prev => ({
...prev,
messages: prev.messages.map(msg =>
msg.id === event.messageId
? { ...msg, content: msg.content + event.delta }
: msg
),
}));
break;
case 'TOOL_CALL_START':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
return { ...prev, toolCalls };
});
break;
case 'TOOL_CALL_ARGS':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
const tc = toolCalls.get(event.toolCallId);
if (tc) {
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
}
return { ...prev, toolCalls };
});
break;
case 'STATE_SNAPSHOT':
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
break;
case 'STATE_DELTA':
setAGUIState(prev => {
const newState = { ...prev.state };
for (const op of event.delta) {
const parts = op.path.split('/').filter(Boolean);
if (op.op === 'replace' || op.op === 'add') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]] = obj[parts[i]] || {};
}
obj[parts[parts.length - 1]] = op.value;
} else if (op.op === 'remove') {
let obj: any = newState;
for (let i = 0; i < parts.length - 1; i++) {
obj = obj[parts[i]];
if (!obj) break;
}
if (obj) delete obj[parts[parts.length - 1]];
}
}
return { ...prev, state: newState };
});
break;
}
}, []);
return { state: aguiState, processEvent };
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Rendering Tool Call Results as UI
</h2>
<p className="text-sm text-muted-foreground mb-4">
When an agent calls a <code className="text-foreground">render_ui</code>{" "}
tool, parse the arguments and render the component:
</p>
<Code lang="tsx">{`import { aguiCatalog } from './catalog';
// Component registry
const components: Record<string, React.FC<any>> = {
Container: ({ direction = 'column', gap = 'md', children }) => (
<div className={\`flex flex-\${direction} gap-\${gap}\`}>{children}</div>
),
Card: ({ title, description, children }) => (
<div className="border rounded-lg p-4">
{title && <h3 className="font-semibold">{title}</h3>}
{description && <p className="text-muted-foreground text-sm">{description}</p>}
{children}
</div>
),
Text: ({ content, variant = 'body' }) => {
const styles = {
body: 'text-sm',
heading: 'text-lg font-semibold',
caption: 'text-xs text-muted-foreground',
code: 'font-mono text-sm bg-muted px-1 rounded',
};
return <p className={styles[variant]}>{content}</p>;
},
Button: ({ label, variant = 'primary', onClick }) => (
<button
className={\`px-4 py-2 rounded text-sm \${
variant === 'primary' ? 'bg-primary text-primary-foreground' :
variant === 'secondary' ? 'bg-secondary text-secondary-foreground' :
'border'
}\`}
onClick={onClick}
>
{label}
</button>
),
Metric: ({ label, value, change, format }) => {
const formatted = format === 'currency' ? \`$\${value.toLocaleString()}\` :
format === 'percent' ? \`\${value}%\` :
value.toLocaleString();
return (
<div className="p-4 border rounded">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{formatted}</p>
{change !== undefined && (
<p className={\`text-sm \${change >= 0 ? 'text-green-600' : 'text-red-600'}\`}>
{change >= 0 ? '+' : ''}{change}%
</p>
)}
</div>
);
},
Alert: ({ message, type = 'info' }) => {
const styles = {
info: 'bg-blue-50 text-blue-800 border-blue-200',
success: 'bg-green-50 text-green-800 border-green-200',
warning: 'bg-yellow-50 text-yellow-800 border-yellow-200',
error: 'bg-red-50 text-red-800 border-red-200',
};
return <div className={\`p-3 rounded border \${styles[type]}\`}>{message}</div>;
},
// Add more components...
};
// Render tool call result
export function renderToolCallUI(toolCall: { name: string; args: string }) {
if (toolCall.name !== 'render_ui') return null;
try {
const parsed = JSON.parse(toolCall.args);
const Component = components[parsed.component];
if (!Component) return null;
return <Component {...parsed.props} />;
} catch {
return null;
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useAGUI } from './use-agui';
import { renderToolCallUI } from './renderer';
export function AGUIChat() {
const { state, processEvent } = useAGUI();
// Connect to AG-UI event stream
async function startRun(prompt: string) {
const response = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\\n').filter(Boolean);
for (const line of lines) {
const event = JSON.parse(line);
processEvent(event);
}
}
}
return (
<div className="space-y-4">
{/* Messages */}
{state.messages.map(msg => (
<div key={msg.id} className={\`p-3 rounded \${
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
}\`}>
{msg.content}
</div>
))}
{/* Rendered UI from tool calls */}
{Array.from(state.toolCalls.values()).map((tc, i) => (
<div key={i}>{renderToolCallUI(tc)}</div>
))}
{/* Input */}
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input?.value) {
startRun(input.value);
input.value = '';
}
}}>
<input
type="text"
placeholder="Ask the agent..."
className="w-full px-4 py-2 border rounded"
disabled={state.isRunning}
/>
</form>
</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/openapi" className="text-foreground hover:underline">
OpenAPI integration
</Link>{" "}
for rendering forms from API schemas.
</p>
</article>
);
}
+112
View File
@@ -0,0 +1,112 @@
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 { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
// 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-haiku-4.5',
system: systemPrompt + contextPrompt,
prompt,
});
return result.toTextStreamResponse();
}`}</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, Renderer } from '@json-render/react';
function GenerativeUI() {
const { spec, isStreaming, error, send } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button
onClick={() => send('Create a dashboard with metrics')}
disabled={isStreaming}
>
{isStreaming ? 'Generating...' : 'Generate'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</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">catalog.prompt()</code> method
creates an optimized system 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>
<p className="text-sm text-muted-foreground mb-4">
Pass custom rules to tailor AI behavior:
</p>
<Code lang="typescript">{`const systemPrompt = catalog.prompt({
customRules: [
'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>
);
}
@@ -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">traverseSpec</h3>
<p className="text-sm text-muted-foreground mb-4">
Walk the UI spec depth-first.
</p>
<Code lang="typescript">{`function traverseSpec(
spec: Spec,
visitor: SpecVisitor,
startKey?: string
): void
interface SpecVisitor {
(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 spec.
</p>
<Code lang="typescript">{`function collectUsedComponents(spec: Spec): Set<string>
// Example
const components = collectUsedComponents(spec);
// 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(spec: Spec): Set<string>
// Example
const paths = collectDataPaths(spec);
// 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 spec.
</p>
<Code lang="typescript">{`function collectActions(spec: Spec): Set<string>
// Example
const actions = collectActions(spec);
// 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 spec */
generate(spec: Spec): 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>
);
}
+403
View File
@@ -0,0 +1,403 @@
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">defineCatalog</h2>
<p className="text-sm text-muted-foreground mb-4">
Creates a type-safe catalog definition with schema validation.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
function defineCatalog<T extends ZodType>(
s: T,
config: CatalogConfig
): Catalog
// Use the React schema for standard UI specs
const catalog = defineCatalog(schema, {
components: {...},
actions: {...},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">CatalogConfig</h3>
<Code lang="typescript">{`interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
functions?: Record<string, FunctionDefinition>;
}
interface ComponentDefinition {
props: ZodObject; // Use .nullable() for optional props
slots?: string[]; // Named slots (e.g., ["default"])
description?: string; // Help AI understand usage
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}
interface FunctionDefinition {
description?: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Instance</h3>
<p className="text-sm text-muted-foreground mb-4">
The returned catalog provides methods for AI prompt generation,
validation, and schema export:
</p>
<Code lang="typescript">{`interface Catalog {
// Data
readonly data: CatalogConfig; // The catalog configuration
readonly componentNames: string[]; // List of component names
readonly actionNames: string[]; // List of action names
// AI Prompt Generation
prompt(options?: PromptOptions): string;
// Validation
validate(spec: unknown): SpecValidationResult;
zodSchema(): z.ZodType; // Get the Zod schema for specs
// Export
jsonSchema(): object; // Export as JSON Schema
}
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
}
interface SpecValidationResult<T> {
success: boolean;
data?: T; // Validated spec (if success)
error?: z.ZodError; // Validation errors (if failed)
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Catalog Methods</h3>
<Code lang="typescript">{`// Generate AI system prompt
const systemPrompt = catalog.prompt({
customRules: ["Always use Card as root element"],
});
// Validate a spec from AI
const result = catalog.validate(aiOutput);
if (result.success) {
render(result.data);
} else {
console.error(result.error);
}
// Get Zod schema for custom validation
const schema = catalog.zodSchema();
const parsed = schema.safeParse(aiOutput);
// Export as JSON Schema (for structured outputs)
const jsonSchema = catalog.jsonSchema();`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema System</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses a flexible schema system that defines both the AI
output format (spec) and what catalogs must provide. Each renderer
package provides its own schema (e.g., @json-render/react exports{" "}
<code className="text-foreground">schema</code>).
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">schema</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema for flat UI element trees. This is exported from
@json-render/react.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
// - Catalog shape: { components: {...}, actions: {...} }
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
description: "Container card",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">defineSchema</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom schemas for different output formats (e.g., page-based,
block-based).
</p>
<Code lang="typescript">{`import { defineSchema } from '@json-render/core';
const mySchema = defineSchema((s) => ({
// What the AI outputs (spec)
spec: s.object({
title: s.string(),
blocks: s.array(s.object({
type: s.ref("catalog.blocks"),
content: s.any(),
})),
}),
// What the catalog must provide
catalog: s.object({
blocks: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Schema Builder API</h3>
<p className="text-sm text-muted-foreground mb-4">
The schema builder provides these methods:
</p>
<Code lang="typescript">{`// Primitive types
s.string() // String value
s.number() // Number value
s.boolean() // Boolean value
s.any() // Any value
// Compound types
s.array(item) // Array of items
s.object({ ... }) // Object with shape
s.record(value) // Record/map with value type
// Catalog references (for type safety)
s.ref("catalog.components") // Reference to catalog key (becomes enum)
s.propsOf("catalog.components") // Props schema from catalog entry
// Catalog definitions
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
s.zod() // Placeholder for user-provided Zod schema
// Modifiers
s.optional() // Mark field as optional`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Zod Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built Zod schemas for common json-render types:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">Dynamic Value Schemas</h3>
<Code lang="typescript">{`import {
DynamicValueSchema, // string | number | boolean | null | { path: string }
DynamicStringSchema, // string | { path: string }
DynamicNumberSchema, // number | { path: string }
DynamicBooleanSchema, // boolean | { path: string }
} from '@json-render/core';
// Dynamic values can be literals or data path references
type DynamicValue<T> = T | { path: string };
// Example: a prop that can be a literal or bound to data
const schema = z.object({
label: DynamicStringSchema, // "Hello" or { path: "/user/name" }
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Visibility &amp; Logic Schemas
</h3>
<Code lang="typescript">{`import {
VisibilityConditionSchema, // Full visibility condition
LogicExpressionSchema, // Logic operators (and, or, not, eq, gt, etc.)
} from '@json-render/core';
// Use in component props that need conditional rendering
const schema = z.object({
visible: VisibilityConditionSchema.optional(),
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Action Schemas</h3>
<Code lang="typescript">{`import {
ActionSchema, // Full action definition
ActionConfirmSchema, // Confirmation dialog config
ActionOnSuccessSchema, // Success handler config
ActionOnErrorSchema, // Error handler config
} from '@json-render/core';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Validation Schemas</h3>
<Code lang="typescript">{`import {
ValidationCheckSchema, // Single validation check
ValidationConfigSchema, // Full validation config with checks array
} from '@json-render/core';`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">SpecStream</h2>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
createSpecStreamCompiler
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create a streaming compiler that incrementally builds a spec:
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
// Get final result
const spec = compiler.getResult();
// Reset for reuse
compiler.reset();`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">compileSpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
Compile an entire SpecStream string at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{}}
{"op":"set","path":"/root/type","value":"Card"}\`;
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Low-Level Utilities</h3>
<Code lang="typescript">{`import {
parseSpecStreamLine,
applySpecStreamPatch,
} from '@json-render/core';
// Parse a single line
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
<Code lang="typescript">{`interface SpecStreamLine {
op: 'set' | 'add' | 'replace' | 'remove';
path: string;
value?: unknown;
}
interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T;
reset(): void;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Utility Functions</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Path Utilities</h3>
<Code lang="typescript">{`import { getByPath, setByPath } from '@json-render/core';
// Get value by JSON Pointer path
const value = getByPath(data, '/user/name'); // "Alice"
// Set value by path (mutates object)
setByPath(data, '/user/email', 'alice@example.com');`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">resolveDynamicValue</h3>
<Code lang="typescript">{`import { resolveDynamicValue } from '@json-render/core';
// Resolve a dynamic value against data
const name = resolveDynamicValue("Hello", data); // "Hello"
const name2 = resolveDynamicValue({ path: "/user/name" }, data); // "Alice"`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">findFormValue</h3>
<Code lang="typescript">{`import { findFormValue } from '@json-render/core';
// Find form values regardless of path format
// Checks: params.name, params["form.name"], data["form.name"], data.form.name
const value = findFormValue("name", params, data);`}</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?: string[]; // Keys of child elements
visible?: VisibilityCondition;
validation?: ValidationSchema;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Spec (Element Tree)</h3>
<Code lang="typescript">{`interface Spec {
root: string | null; // Key of root element
elements: Record<string, UIElement>;
}`}</Code>
<p className="text-sm text-muted-foreground mt-2 mb-4">
Elements are stored as a flat map with string keys. The tree structure
is built by following the{" "}
<code className="text-foreground">children</code> arrays.
</p>
<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>
);
}
+143
View File
@@ -0,0 +1,143 @@
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">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</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
spec={Spec} // The UI spec to render
registry={Registry} // Component registry (from defineRegistry)
loading={boolean} // Optional loading state
fallback={Component} // Optional fallback for unknown types
/>
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</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 {
spec, // Spec - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string) => void
abort, // () => void
} = useUIStream({
api: string, // API endpoint URL
onChunk?: (chunk: string) => void, // Called for each chunk
onFinish?: (spec: Spec) => void, // Called when streaming completes
});`}</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>
);
}
@@ -0,0 +1,240 @@
import { Code } from "@/components/code";
export const metadata = {
title: "@json-render/remotion API | json-render",
};
export default function RemotionApiPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">@json-render/remotion</h1>
<p className="text-muted-foreground mb-8">
Remotion video renderer. Turn JSON timeline specs into video
compositions.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">schema</h2>
<p className="text-sm text-muted-foreground mb-4">
The timeline schema for video specs. Use with{" "}
<code className="text-foreground">defineCatalog</code> from core.
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/remotion';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
The main composition component that renders timeline specs. Use with
Remotion&apos;s Player or in a Remotion project.
</p>
<Code lang="tsx">{`import { Player } from '@remotion/player';
import { Renderer } from '@json-render/remotion';
function VideoPlayer({ spec }) {
return (
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
controls
/>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Custom Components</h3>
<p className="text-sm text-muted-foreground mb-4">
Pass custom components to the Renderer:
</p>
<Code lang="tsx">{`import { Renderer, standardComponents } from '@json-render/remotion';
const customComponents = {
...standardComponents,
MyCustomClip: ({ clip }) => <div>{clip.props.text}</div>,
};
<Player
component={Renderer}
inputProps={{ spec, components: customComponents }}
// ...
/>`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Standard Components</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built video components included in the package:
</p>
<Code lang="typescript">{`import {
TitleCard, // Full-screen title with subtitle
ImageSlide, // Full-screen image display
SplitScreen, // Two-column layout
QuoteCard, // Quote with attribution
StatCard, // Large statistic display
LowerThird, // Name/title overlay
TextOverlay, // Centered text overlay
TypingText, // Terminal typing animation
LogoBug, // Corner logo watermark
VideoClip, // Video playback
} from '@json-render/remotion';`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TitleCard Props</h3>
<Code lang="typescript">{`{
title: string;
subtitle?: string;
backgroundColor?: string; // default: "#1a1a1a"
textColor?: string; // default: "#ffffff"
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TypingText Props</h3>
<Code lang="typescript">{`{
text: string;
charsPerSecond?: number; // default: 15
showCursor?: boolean; // default: true
cursorChar?: string; // default: "|"
fontFamily?: string; // default: "monospace"
fontSize?: number; // default: 48
textColor?: string; // default: "#00ff00"
backgroundColor?: string; // default: "#1e1e1e"
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Catalog Definitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Pre-built definitions for creating catalogs:
</p>
<Code lang="typescript">{`import {
standardComponentDefinitions, // All standard component definitions
standardTransitionDefinitions, // fade, slideLeft, slideRight, etc.
standardEffectDefinitions, // kenBurns, pulseGlow, colorShift
} from '@json-render/remotion';
// Use in your catalog
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
// Add custom components
},
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Hooks &amp; Utilities
</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useTransition</h3>
<p className="text-sm text-muted-foreground mb-4">
Calculate transition styles for a clip based on current frame:
</p>
<Code lang="typescript">{`import { useTransition } from '@json-render/remotion';
import { useCurrentFrame } from 'remotion';
function MyComponent({ clip }) {
const frame = useCurrentFrame();
const transition = useTransition(clip, frame);
return (
<div style={{
opacity: transition.opacity,
transform: transition.transform,
}}>
Content
</div>
);
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ClipWrapper</h3>
<p className="text-sm text-muted-foreground mb-4">
Automatically apply transitions to clip content:
</p>
<Code lang="tsx">{`import { ClipWrapper } from '@json-render/remotion';
function MyClip({ clip }) {
return (
<ClipWrapper clip={clip}>
<div>My content with automatic transitions</div>
</ClipWrapper>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Types</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">TimelineSpec</h3>
<Code lang="typescript">{`interface TimelineSpec {
composition: {
id: string;
fps: number;
width: number;
height: number;
durationInFrames: number;
};
tracks: Track[];
clips: Clip[];
audio: {
tracks: AudioTrack[];
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">Clip</h3>
<Code lang="typescript">{`interface Clip {
id: string;
trackId: string;
component: string;
props: Record<string, unknown>;
from: number;
durationInFrames: number;
transitionIn?: {
type: string;
durationInFrames: number;
};
transitionOut?: {
type: string;
durationInFrames: number;
};
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">TransitionStyles</h3>
<Code lang="typescript">{`interface TransitionStyles {
opacity: number;
transform: string;
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">ComponentRegistry</h3>
<Code lang="typescript">{`type ClipComponent = React.ComponentType<{ clip: Clip }>;
type ComponentRegistry = Record<string, ClipComponent>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Transitions</h2>
<p className="text-sm text-muted-foreground mb-4">
Available transition types:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code className="text-foreground">fade</code> - Opacity fade in/out
</li>
<li>
<code className="text-foreground">slideLeft</code> - Slide from right
</li>
<li>
<code className="text-foreground">slideRight</code> - Slide from left
</li>
<li>
<code className="text-foreground">slideUp</code> - Slide from bottom
</li>
<li>
<code className="text-foreground">slideDown</code> - Slide from top
</li>
<li>
<code className="text-foreground">zoom</code> - Scale zoom in/out
</li>
<li>
<code className="text-foreground">wipe</code> - Horizontal wipe
</li>
</ul>
</article>
);
}
+126
View File
@@ -0,0 +1,126 @@
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 (with props and optional slots)
</li>
<li>
<strong className="text-foreground">Actions</strong> — Operations AI
can trigger
</li>
<li>
<strong className="text-foreground">Functions</strong> — Custom
validation or transformation functions
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Creating a Catalog</h2>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
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']).nullable(),
}),
slots: ["default"], // Can contain other components
description: "Container card for grouping content",
},
Metric: {
props: z.object({
label: z.string(),
valuePath: z.string(), // JSON Pointer to data
format: z.enum(['currency', 'percent', 'number']),
}),
description: "Display a single metric value",
},
},
actions: {
submit_form: {
params: z.object({
formId: z.string(),
}),
description: 'Submit a form',
},
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
}),
description: 'Export data in various formats',
},
},
});`}</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 (use .nullable() for optional)
slots?: string[], // Named slots for children (e.g., ["default"])
description?: string, // Help AI understand when to use it
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Use{" "}
<code className="text-foreground">slots: [&quot;default&quot;]</code>{" "}
for components that can contain children. The slot name corresponds to
where child elements are rendered.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">
Generating AI Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the <code className="text-foreground">catalog.prompt()</code> method
to generate a system prompt for AI:
</p>
<Code lang="typescript">{`// Generate a system prompt from your catalog
const systemPrompt = catalog.prompt();
// Or with custom rules for the AI
const customPrompt = catalog.prompt({
customRules: [
"Always use Card as the root element for forms",
"Group related inputs in a Stack with direction=vertical",
],
});
// 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/registry" className="text-foreground hover:underline">
register components
</Link>{" "}
in your registry.
</p>
</article>
);
}
+207
View File
@@ -0,0 +1,207 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Changelog | json-render",
};
export default function ChangelogPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Changelog</h1>
<p className="text-muted-foreground mb-8">
Notable changes and updates to json-render.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">v0.4.0</h2>
<p className="text-sm text-muted-foreground mb-6">February 2026</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: Custom Schema System
</h3>
<p className="text-sm text-muted-foreground mb-4">
Create custom output formats with <code>defineSchema</code>. Each
renderer now defines its own schema, enabling completely different spec
formats for different use cases.
</p>
<Code lang="typescript">{`import { defineSchema } from "@json-render/core";
const mySchema = defineSchema((s) => ({
spec: s.object({
pages: s.array(s.object({
title: s.string(),
blocks: s.array(s.ref("catalog.blocks")),
})),
}),
catalog: s.object({
blocks: s.map({ props: s.zod(), description: s.string() }),
}),
}), {
promptTemplate: myPromptTemplate,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">New: Component Slots</h3>
<p className="text-sm text-muted-foreground mb-4">
Components can now define which slots they accept. Use{" "}
<code>[&quot;default&quot;]</code> for regular children, or named slots
like <code>[&quot;header&quot;, &quot;footer&quot;]</code> for more
complex layouts.
</p>
<Code lang="typescript">{`const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"], // accepts children
description: "A card container",
},
Layout: {
props: z.object({}),
slots: ["header", "content", "footer"], // named slots
description: "Page layout with header, content, footer",
},
},
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: AI Prompt Generation
</h3>
<p className="text-sm text-muted-foreground mb-4">
Catalogs now generate AI system prompts automatically with{" "}
<code>catalog.prompt()</code>. The prompt includes all component
definitions, props schemas, and action descriptions - ensuring the AI
only generates valid specs.
</p>
<Code lang="typescript">{`import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
const catalog = defineCatalog(schema, {
components: { /* ... */ },
actions: { /* ... */ },
});
// Generate system prompt for AI
const systemPrompt = catalog.prompt();
// Use with any AI SDK
const result = await streamText({
model: "claude-haiku-4.5",
system: systemPrompt,
prompt: userMessage,
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
New: @json-render/remotion
</h3>
<p className="text-sm text-muted-foreground mb-4">
Generate AI-powered videos with Remotion. Define video catalogs, stream
timeline specs, and render with the Remotion Player.
</p>
<Code lang="tsx">{`import { Player } from "@remotion/player";
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
});
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
/>`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Includes 10 standard video components (TitleCard, TypingText,
SplitScreen, etc.), 7 transition types, and the ClipWrapper utility for
custom components.
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">New: SpecStream</h3>
<p className="text-sm text-muted-foreground mb-4">
SpecStream is json-render&apos;s streaming format for progressively
building specs from JSONL patches. The new compiler API makes it easy to
process streaming AI responses.
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from "@json-render/core";
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
setSpec(result); // Update UI with partial result`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Dashboard Example
</h3>
<p className="text-sm text-muted-foreground mb-4">
The dashboard example is now a full-featured accounting dashboard with:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Persistent SQLite database with Drizzle ORM</li>
<li>RESTful API for customers, invoices, expenses, accounts</li>
<li>Draggable widget reordering</li>
<li>AI-powered widget generation with streaming</li>
<li>Real data binding to database records</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">
Improved: Documentation
</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Interactive playground for testing specs</li>
<li>New guides: Custom Schema, Streaming, Code Export</li>
<li>Full API reference for all packages</li>
<li>Integration guides: A2UI, AG-UI, Adaptive Cards, OpenAPI</li>
</ul>
<h3 className="text-lg font-semibold mt-8 mb-4">Breaking Changes</h3>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<code>UITree</code> type renamed to <code>Spec</code>
</li>
<li>
Schema is now imported from renderer packages (
<code>@json-render/react</code>) not core
</li>
<li>
<code>defineCatalog</code> now requires a schema as first argument
</li>
</ul>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.3.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Internal release with codegen foundations.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
Added <code>@json-render/codegen</code> package (spec traversal and
JSX serialization)
</li>
<li>Configurable AI model via environment variables</li>
<li>Documentation improvements and bug fixes</li>
</ul>
<p className="text-sm text-muted-foreground italic">
Note: Only @json-render/core was published to npm for this release.
</p>
<hr className="my-12 border-border" />
<h2 className="text-xl font-semibold mt-12 mb-4">v0.2.0</h2>
<p className="text-sm text-muted-foreground mb-6">January 2026</p>
<p className="text-sm text-muted-foreground mb-4">
Initial public release.
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Core catalog and spec types</li>
<li>React renderer with contexts for data, actions, visibility</li>
<li>AI prompt generation from catalogs</li>
<li>Basic streaming support</li>
<li>Dashboard example application</li>
</ul>
</article>
);
}
@@ -0,0 +1,170 @@
import { Code } from "@/components/code";
export const metadata = {
title: "Code Export | json-render",
};
export default function CodeExportPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Code Export</h1>
<p className="text-muted-foreground mb-8">
Export generated UI as standalone code for your framework.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
While json-render is designed for dynamic rendering, you can export
generated UI as static code. The code generation is intentionally
project-specific so you have full control over:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-8">
<li>Component templates (standalone, no json-render dependencies)</li>
<li>Package.json and project structure</li>
<li>Framework-specific patterns (Next.js, Remix, etc.)</li>
<li>How data is passed to components</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">Architecture</h2>
<p className="text-sm text-muted-foreground mb-4">
Code export is split into two parts:
</p>
<h3 className="text-lg font-semibold mt-8 mb-4">
1. @json-render/codegen (utilities)
</h3>
<p className="text-sm text-muted-foreground mb-4">
Framework-agnostic utilities for building code generators:
</p>
<Code lang="typescript">{`import {
traverseSpec, // Walk the UI spec
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(spec: Spec): GeneratedFile[] {
const components = collectUsedComponents(spec);
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">traverseSpec</h3>
<Code lang="typescript">{`import { traverseSpec } from '@json-render/codegen';
traverseSpec(spec, (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(spec);
// 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>
);
}
@@ -1,19 +1,37 @@
---
title: "Custom Schema & Renderer"
---
import Link from "next/link";
import { Code } from "@/components/code";
Build your own schema and renderer with `@json-render/core`.
export const metadata = {
title: "Custom Schema & Renderer | json-render",
};
## Overview
export default function CustomSchemaPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Custom Schema & Renderer</h1>
<p className="text-muted-foreground mb-8">
Build your own schema and renderer with{" "}
<code className="text-foreground">@json-render/core</code>.
</p>
`@json-render/core` is schema-agnostic. While `@json-render/react` provides a ready-to-use schema and renderer, you can create your own to match any JSON structure - whether it's a domain-specific format, an existing protocol, or something entirely custom.
<h2 className="text-xl font-semibold mt-12 mb-4">Overview</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. While{" "}
<code className="text-foreground">@json-render/react</code> provides a
ready-to-use schema and renderer, you can create your own to match any
JSON structure - whether it&apos;s a domain-specific format, an existing
protocol, or something entirely custom.
</p>
## 1. Define Your Schema
Start by defining the JSON structure your system will use. Here's an example of a simple dashboard schema:
```json
{
<h2 className="text-xl font-semibold mt-12 mb-4">
1. Define Your Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Start by defining the JSON structure your system will use. Here&apos;s
an example of a simple dashboard schema:
</p>
<Code lang="json">{`{
"layout": "grid",
"columns": 2,
"widgets": [
@@ -36,18 +54,18 @@ Start by defining the JSON structure your system will use. Here's an example of
"dataKey": "orders"
}
]
}
```
}`}</Code>
## 2. Create the Catalog
Define a catalog that describes your components and validates props using `defineCatalog` — see [Catalog](/docs/catalog).
```typescript
import { defineCatalog } from '@json-render/core';
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create the Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define a catalog that describes your components and validates props:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const dashboardCatalog = defineCatalog(mySchema, {
export const dashboardCatalog = createCatalog({
components: {
metric: {
description: 'Displays a single metric value',
@@ -84,15 +102,15 @@ export const dashboardCatalog = defineCatalog(mySchema, {
}),
},
},
});
```
});`}</Code>
## 3. Define the Root Schema
Create a schema for the overall document structure:
```typescript
import { z } from 'zod';
<h2 className="text-xl font-semibold mt-12 mb-4">
3. Define the Root Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a schema for the overall document structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
const WidgetSchema = z.object({
type: z.string(),
@@ -107,15 +125,15 @@ export const DashboardSchema = z.object({
});
export type Dashboard = z.infer<typeof DashboardSchema>;
export type Widget = z.infer<typeof WidgetSchema>;
```
export type Widget = z.infer<typeof WidgetSchema>;`}</Code>
## 4. Build the Renderer
Create a renderer that maps your schema to React components:
```tsx
import React from 'react';
<h2 className="text-xl font-semibold mt-12 mb-4">
4. Build the Renderer
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a renderer that maps your schema to React components:
</p>
<Code lang="tsx">{`import React from 'react';
import { dashboardCatalog } from './catalog';
import type { Dashboard, Widget } from './schema';
@@ -126,7 +144,7 @@ const widgetComponents: Record<string, React.FC<any>> = {
<p className="text-sm text-muted-foreground">{title}</p>
<p className="text-2xl font-bold">{value}</p>
{trend && (
<p className={`text-sm ${trend === 'up' ? 'text-green-500' : 'text-red-500'}`}>
<p className={\`text-sm \${trend === 'up' ? 'text-green-500' : 'text-red-500'}\`}>
{trend === 'up' ? '+' : '-'}{change}
</p>
)}
@@ -186,7 +204,7 @@ export function DashboardRenderer({
data?: Record<string, any>;
}) {
const layoutClass = {
grid: `grid gap-4 ${spec.columns ? `grid-cols-${spec.columns}` : 'grid-cols-2'}`,
grid: \`grid gap-4 \${spec.columns ? \`grid-cols-\${spec.columns}\` : 'grid-cols-2'}\`,
stack: 'flex flex-col gap-4',
tabs: 'space-y-4',
}[spec.layout];
@@ -196,7 +214,7 @@ export function DashboardRenderer({
{spec.widgets.map((widget, index) => {
const Component = widgetComponents[widget.type];
if (!Component) {
console.warn(`Unknown widget type: ${widget.type}`);
console.warn(\`Unknown widget type: \${widget.type}\`);
return null;
}
@@ -213,15 +231,15 @@ export function DashboardRenderer({
})}
</div>
);
}
```
}`}</Code>
## 5. Generate LLM Prompts
Use the catalog to generate system prompts for AI:
```typescript
const systemPrompt = dashboardCatalog.prompt({
<h2 className="text-xl font-semibold mt-12 mb-4">
5. Generate LLM Prompts
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the catalog to generate system prompts for AI:
</p>
<Code lang="typescript">{`const systemPrompt = dashboardCatalog.prompt({
customRules: [
'Use metric widgets for single KPI values',
'Use chart widgets for time-series data',
@@ -235,14 +253,14 @@ const response = await generateText({
model: 'gpt-4',
system: systemPrompt,
prompt: 'Create a sales dashboard with revenue, orders, and a chart',
});
```
});`}</Code>
## 6. Validate Specs
<h2 className="text-xl font-semibold mt-12 mb-4">6. Validate Specs</h2>
<p className="text-sm text-muted-foreground mb-4">
Validate incoming specs against your schema:
</p>
<Code lang="typescript">{`import { validate } from '@json-render/core';
Validate incoming specs against your schema. Use `catalog.validate()` to check AI output against the catalog's Zod schema:
```typescript
function validateDashboard(spec: unknown) {
// Validate root structure
const rootResult = DashboardSchema.safeParse(spec);
@@ -250,20 +268,23 @@ function validateDashboard(spec: unknown) {
return { valid: false, errors: rootResult.error.errors };
}
// Validate each widget's props against the catalog
const result = dashboardCatalog.validate(spec);
if (!result.success) {
return { valid: false, errors: result.error.errors };
// Validate each widget against catalog
const errors: string[] = [];
for (const widget of rootResult.data.widgets) {
const result = validate(
{ type: widget.type, props: widget },
dashboardCatalog
);
if (!result.valid) {
errors.push(...result.errors.map(e => \`\${widget.type}: \${e}\`));
}
}
return { valid: true, errors: [] };
}
```
return { valid: errors.length === 0, errors };
}`}</Code>
## Usage Example
```tsx
'use client';
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { useState } from 'react';
import { DashboardRenderer } from './renderer';
@@ -292,9 +313,23 @@ export function MyDashboard() {
const [spec, setSpec] = useState(initialSpec);
return <DashboardRenderer spec={spec} data={data} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
See how to integrate with{" "}
<Link href="/docs/a2ui" className="text-foreground hover:underline">
A2UI
</Link>{" "}
or{" "}
<Link
href="/docs/adaptive-cards"
className="text-foreground hover:underline"
>
Adaptive Cards
</Link>{" "}
protocols.
</p>
</article>
);
}
```
## Next
See how to integrate with [A2UI](/docs/a2ui) or [Adaptive Cards](/docs/adaptive-cards) protocols.
@@ -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>
);
}
@@ -0,0 +1,43 @@
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 package plus your renderer of choice.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">For React UI</h2>
<PackageInstall packages="@json-render/core @json-render/react" />
<h2 className="text-xl font-semibold mt-12 mb-4">For Remotion Video</h2>
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
<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>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar";
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 sticky top-28 h-[calc(100vh-7rem)] overflow-y-auto">
<DocsSidebar />
</aside>
{/* Content */}
<div className="flex-1 min-w-0 max-w-2xl">{children}</div>
</div>
</>
);
}
+718
View File
@@ -0,0 +1,718 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "OpenAPI Integration | json-render",
};
export default function OpenAPIPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">OpenAPI Integration</h1>
<p className="text-muted-foreground mb-8">
Use json-render to generate dynamic forms and UIs from{" "}
<a
href="https://swagger.io/specification/"
target="_blank"
rel="noopener noreferrer"
className="text-foreground hover:underline"
>
OpenAPI/Swagger
</a>{" "}
schemas.
</p>
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can
support OpenAPI schemas. The examples are illustrative and may require
adaptation for production use.
</p>
</div>
<h2 className="text-xl font-semibold mt-12 mb-4">Why OpenAPI?</h2>
<p className="text-sm text-muted-foreground mb-4">
OpenAPI specifications describe your API{"'"}s endpoints, request
bodies, and response schemas. By converting OpenAPI schemas to
json-render specs, you can:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Automatically generate forms for API endpoints</li>
<li>Display API responses with type-aware rendering</li>
<li>Keep your UI in sync with your API schema</li>
<li>Let AI generate UIs that match your API contracts</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Example OpenAPI Schema
</h2>
<p className="text-sm text-muted-foreground mb-4">
A typical OpenAPI schema for a request body:
</p>
<Code lang="json">{`{
"openapi": "3.0.0",
"paths": {
"/users": {
"post": {
"summary": "Create a new user",
"operationId": "createUser",
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateUserRequest"
}
}
}
}
}
}
},
"components": {
"schemas": {
"CreateUserRequest": {
"type": "object",
"required": ["email", "name"],
"properties": {
"name": {
"type": "string",
"description": "User's full name",
"minLength": 1,
"maxLength": 100
},
"email": {
"type": "string",
"format": "email",
"description": "User's email address"
},
"age": {
"type": "integer",
"minimum": 0,
"maximum": 150,
"description": "User's age"
},
"role": {
"type": "string",
"enum": ["admin", "user", "guest"],
"default": "user",
"description": "User's role"
},
"preferences": {
"type": "object",
"properties": {
"newsletter": {
"type": "boolean",
"default": false
},
"theme": {
"type": "string",
"enum": ["light", "dark", "system"]
}
}
}
}
}
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Define an OpenAPI-to-UI Catalog
</h2>
<p className="text-sm text-muted-foreground mb-4">
Create components that map to OpenAPI data types:
</p>
<Code lang="typescript">{`import { createCatalog } from '@json-render/core';
import { z } from 'zod';
export const openapiCatalog = createCatalog({
components: {
// Form container
Form: {
description: 'API form container',
props: z.object({
operationId: z.string(),
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']),
title: z.string().optional(),
description: z.string().optional(),
}),
},
// Field components mapped to OpenAPI types
StringField: {
description: 'String input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
format: z.enum(['text', 'email', 'uri', 'uuid', 'date', 'date-time', 'password']).optional(),
minLength: z.number().optional(),
maxLength: z.number().optional(),
pattern: z.string().optional(),
placeholder: z.string().optional(),
defaultValue: z.string().optional(),
}),
},
NumberField: {
description: 'Number input field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
type: z.enum(['integer', 'number']).optional(),
minimum: z.number().optional(),
maximum: z.number().optional(),
exclusiveMinimum: z.number().optional(),
exclusiveMaximum: z.number().optional(),
multipleOf: z.number().optional(),
defaultValue: z.number().optional(),
}),
},
BooleanField: {
description: 'Boolean toggle field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
defaultValue: z.boolean().optional(),
}),
},
EnumField: {
description: 'Enum selection field',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
required: z.boolean().optional(),
options: z.array(z.object({
value: z.string(),
label: z.string().optional(),
})),
defaultValue: z.string().optional(),
}),
},
ArrayField: {
description: 'Array of items',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
minItems: z.number().optional(),
maxItems: z.number().optional(),
uniqueItems: z.boolean().optional(),
}),
},
ObjectField: {
description: 'Nested object group',
props: z.object({
name: z.string(),
label: z.string(),
description: z.string().optional(),
collapsible: z.boolean().optional(),
}),
},
// Response display components
ResponseDisplay: {
description: 'Displays API response',
props: z.object({
status: z.number(),
statusText: z.string().optional(),
}),
},
SchemaTable: {
description: 'Displays data matching a schema',
props: z.object({
schema: z.string(),
data: z.array(z.record(z.unknown())),
}),
},
},
actions: {
submit: {
description: 'Submit form to API endpoint',
params: z.object({
operationId: z.string(),
}),
},
reset: {
description: 'Reset form to defaults',
params: z.object({}),
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Convert OpenAPI Schema to Spec
</h2>
<p className="text-sm text-muted-foreground mb-4">
Transform OpenAPI schemas into json-render specs:
</p>
<Code lang="typescript">{`interface OpenAPISchema {
type?: string;
format?: string;
enum?: string[];
properties?: Record<string, OpenAPISchema>;
items?: OpenAPISchema;
required?: string[];
description?: string;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
default?: unknown;
}
interface SpecElement {
key: string;
type: string;
props: Record<string, unknown>;
children: string[];
parentKey: string;
}
function schemaToSpec(
schema: OpenAPISchema,
name: string,
required: string[] = [],
parentKey: string = '',
elements: Map<string, SpecElement> = new Map(),
): string {
const key = parentKey ? \`\${parentKey}-\${name}\` : name;
const isRequired = required.includes(name);
const label = name.charAt(0).toUpperCase() + name.slice(1).replace(/([A-Z])/g, ' $1');
if (schema.enum) {
elements.set(key, {
key,
type: 'EnumField',
props: {
name,
label,
description: schema.description,
required: isRequired,
options: schema.enum.map(v => ({ value: v, label: v })),
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'string') {
elements.set(key, {
key,
type: 'StringField',
props: {
name,
label,
description: schema.description,
required: isRequired,
format: schema.format || 'text',
minLength: schema.minLength,
maxLength: schema.maxLength,
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'integer' || schema.type === 'number') {
elements.set(key, {
key,
type: 'NumberField',
props: {
name,
label,
description: schema.description,
required: isRequired,
type: schema.type,
minimum: schema.minimum,
maximum: schema.maximum,
defaultValue: schema.default as number,
},
children: [],
parentKey,
});
} else if (schema.type === 'boolean') {
elements.set(key, {
key,
type: 'BooleanField',
props: {
name,
label,
description: schema.description,
defaultValue: schema.default as boolean,
},
children: [],
parentKey,
});
} else if (schema.type === 'array' && schema.items) {
const childKeys: string[] = [];
const itemKey = schemaToSpec(schema.items, 'item', [], key, elements);
childKeys.push(itemKey);
elements.set(key, {
key,
type: 'ArrayField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
} else if (schema.type === 'object' && schema.properties) {
const childKeys: string[] = [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
propName,
schema.required || [],
key,
elements,
);
childKeys.push(childKey);
}
elements.set(key, {
key,
type: 'ObjectField',
props: {
name,
label,
description: schema.description,
},
children: childKeys,
parentKey,
});
}
return key;
}
// Convert full OpenAPI operation to spec
export function operationToSpec(
operationId: string,
method: string,
path: string,
schema: OpenAPISchema,
title?: string,
description?: string,
) {
const elements = new Map<string, SpecElement>();
const rootKey = 'form';
const childKeys: string[] = [];
if (schema.properties) {
for (const [name, propSchema] of Object.entries(schema.properties)) {
const childKey = schemaToSpec(
propSchema,
name,
schema.required || [],
rootKey,
elements,
);
childKeys.push(childKey);
}
}
elements.set(rootKey, {
key: rootKey,
type: 'Form',
props: {
operationId,
endpoint: path,
method: method.toUpperCase(),
title,
description,
},
children: childKeys,
parentKey: '',
});
return {
root: rootKey,
elements: Object.fromEntries(elements),
};
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Build an OpenAPI Form Renderer
</h2>
<Code lang="tsx">{`'use client';
import React, { useState } from 'react';
interface FieldProps {
name: string;
value: unknown;
onChange: (name: string, value: unknown) => void;
}
const fields: Record<string, React.FC<any>> = {
StringField: ({ name, label, description, required, format, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type={format === 'email' ? 'email' : format === 'password' ? 'password' : 'text'}
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
/>
</div>
),
NumberField: ({ name, label, description, required, minimum, maximum, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<input
type="number"
className="w-full px-3 py-2 border rounded text-sm"
value={(value as number) ?? ''}
min={minimum}
max={maximum}
onChange={(e) => onChange(name, e.target.value ? parseFloat(e.target.value) : undefined)}
required={required}
/>
</div>
),
BooleanField: ({ name, label, description, value, onChange }) => (
<div className="flex items-start gap-2">
<input
type="checkbox"
id={name}
checked={Boolean(value)}
onChange={(e) => onChange(name, e.target.checked)}
className="mt-1"
/>
<div>
<label htmlFor={name} className="text-sm font-medium">{label}</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</div>
</div>
),
EnumField: ({ name, label, description, required, options, value, onChange }) => (
<div className="space-y-1">
<label className="text-sm font-medium">
{label} {required && <span className="text-red-500">*</span>}
</label>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
<select
className="w-full px-3 py-2 border rounded text-sm"
value={(value as string) || ''}
onChange={(e) => onChange(name, e.target.value)}
required={required}
>
<option value="">Select...</option>
{options?.map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label || opt.value}
</option>
))}
</select>
</div>
),
ObjectField: ({ name, label, description, children }) => (
<fieldset className="border rounded p-4 space-y-4">
<legend className="text-sm font-medium px-2">{label}</legend>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{children}
</fieldset>
),
Form: ({ title, description, endpoint, method, children, onSubmit }) => (
<form
className="space-y-4 max-w-md"
onSubmit={(e) => {
e.preventDefault();
onSubmit?.();
}}
>
{title && <h2 className="text-lg font-semibold">{title}</h2>}
{description && <p className="text-sm text-muted-foreground">{description}</p>}
{children}
<button
type="submit"
className="px-4 py-2 bg-primary text-primary-foreground rounded text-sm"
>
{method === 'POST' ? 'Create' : method === 'PUT' ? 'Update' : 'Submit'}
</button>
</form>
),
};
interface OpenAPIFormProps {
spec: {
root: string;
elements: Record<string, any>;
};
onSubmit: (data: Record<string, unknown>) => void;
}
export function OpenAPIForm({ spec, onSubmit }: OpenAPIFormProps) {
const [formData, setFormData] = useState<Record<string, unknown>>({});
const handleChange = (name: string, value: unknown) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
function renderElement(key: string): React.ReactNode {
const element = spec.elements[key];
if (!element) return null;
const Field = fields[element.type];
if (!Field) return null;
const children = element.children?.map(renderElement);
return (
<Field
key={key}
{...element.props}
value={formData[element.props.name]}
onChange={handleChange}
onSubmit={() => onSubmit(formData)}
>
{children}
</Field>
);
}
return <>{renderElement(spec.root)}</>;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Usage Example</h2>
<Code lang="tsx">{`'use client';
import { OpenAPIForm } from './openapi-form';
import { operationToSpec } from './openapi-to-spec';
// Your OpenAPI schema (typically loaded from your API)
const createUserSchema = {
type: 'object',
required: ['email', 'name'],
properties: {
name: { type: 'string', description: "User's full name" },
email: { type: 'string', format: 'email', description: "User's email" },
age: { type: 'integer', minimum: 0, maximum: 150 },
role: { type: 'string', enum: ['admin', 'user', 'guest'], default: 'user' },
},
};
// Convert to spec
const spec = operationToSpec(
'createUser',
'POST',
'/api/users',
createUserSchema,
'Create User',
'Add a new user to the system',
);
export function CreateUserForm() {
const handleSubmit = async (data: Record<string, unknown>) => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
console.log('User created!');
}
};
return <OpenAPIForm spec={spec} onSubmit={handleSubmit} />;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Auto-generating from OpenAPI Document
</h2>
<p className="text-sm text-muted-foreground mb-4">
Load and parse an OpenAPI document to generate forms for all operations:
</p>
<Code lang="typescript">{`import SwaggerParser from '@apidevtools/swagger-parser';
import { operationToSpec } from './openapi-to-spec';
interface OpenAPIDocument {
paths: Record<string, Record<string, {
operationId?: string;
summary?: string;
description?: string;
requestBody?: {
content?: {
'application/json'?: {
schema?: any;
};
};
};
}>>;
components?: {
schemas?: Record<string, any>;
};
}
export async function loadOpenAPISpecs(specUrl: string) {
const api = await SwaggerParser.dereference(specUrl) as OpenAPIDocument;
const specs: Record<string, any> = {};
for (const [path, methods] of Object.entries(api.paths)) {
for (const [method, operation] of Object.entries(methods)) {
if (!operation.requestBody?.content?.['application/json']?.schema) continue;
const schema = operation.requestBody.content['application/json'].schema;
const operationId = operation.operationId || \`\${method}_\${path.replace(/\\//g, '_')}\`;
specs[operationId] = operationToSpec(
operationId,
method,
path,
schema,
operation.summary,
operation.description,
);
}
}
return specs;
}
// Usage
const specs = await loadOpenAPISpecs('https://api.example.com/openapi.json');
// specs.createUser, specs.updateUser, etc.`}</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"
>
streaming
</Link>{" "}
for progressive UI rendering.
</p>
</article>
);
}
+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>
);
}
@@ -0,0 +1,220 @@
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 { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
export const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({
title: z.string(),
description: z.string().nullable(),
}),
slots: ["default"],
description: "Container card with optional title",
},
Button: {
props: z.object({
label: z.string(),
action: z.string().nullable(),
}),
description: "Clickable button that triggers an action",
},
Text: {
props: z.object({
content: z.string(),
}),
description: "Text paragraph",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
navigate: {
params: z.object({ url: z.string() }),
description: "Navigate to a URL",
},
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{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 { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
// Generate system prompt from catalog
const systemPrompt = catalog.prompt();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: systemPrompt,
prompt,
});
return result.toTextStreamResponse();
}`}</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 providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { spec, isStreaming, send } = useUIStream({
api: '/api/generate',
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
send(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={isStreaming}>
Generate
</button>
</form>
<div className="mt-8">
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</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>
);
}
+248
View File
@@ -0,0 +1,248 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Registry | json-render",
};
export default function RegistryPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Registry</h1>
<p className="text-muted-foreground mb-8">
Register React components and action handlers to bring your catalog to
life.
</p>
{/* defineRegistry */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Use <code>defineRegistry</code> to create a type-safe registry from your
catalog. Pass your components, actions, or both in a single call:
</p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
actions: {
submit_form: async (params, setData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(params),
});
const result = await res.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
The returned object contains:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>
<code>registry</code> - component registry for{" "}
<code>{"<Renderer />"}</code>
</li>
<li>
<code>handlers</code> - factory for ActionProvider-compatible handlers
</li>
<li>
<code>executeAction</code> - imperative action dispatch (for use
outside the React tree)
</li>
</ul>
{/* Component Props */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component in the registry receives a <code>ComponentContext</code>{" "}
object:
</p>
<Code lang="typescript">{`interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: ActionTrigger) => void; // Dispatch an action
loading?: boolean; // Whether the renderer is in a loading state
}`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
Props are automatically inferred from your catalog, so{" "}
<code>props.title</code> is typed as <code>string</code> if your catalog
defines it that way.
</p>
{/* Action Handlers */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</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>
<h3 className="text-lg font-medium mt-8 mb-3">Defining Actions</h3>
<p className="text-sm text-muted-foreground mb-4">
Define available actions in your catalog:
</p>
<Code lang="typescript">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
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']),
}),
},
navigate: {
params: z.object({
url: z.string(),
}),
},
},
});`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4">
Action handlers receive <code>(params, setData, data)</code> and are
defined inside <code>defineRegistry</code>:
</p>
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
actions: {
submit_form: async (params, setData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
const result = await response.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
},
});`}</Code>
{/* Using Data Binding */}
<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 inside your registry components to read and write data:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
import { getByPath } from '@json-render/core';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useData();
const value = getByPath(data, props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
},
TextField: ({ props }) => {
const { data, set } = useData();
const value = getByPath(data, props.valuePath) as string;
return (
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
placeholder={props.placeholder}
/>
);
},`}</Code>
{/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<p className="text-sm text-muted-foreground mb-4">
Wire everything together with providers and the{" "}
<code>{"<Renderer />"}</code> component:
</p>
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
import {
Renderer,
DataProvider,
VisibilityProvider,
ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setData }) {
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
const actionHandlers = useMemo(
() => handlers(() => setDataRef.current, () => dataRef.current),
[],
);
return (
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</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>
);
}
+230
View File
@@ -0,0 +1,230 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Schemas | json-render",
};
export default function SchemasPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Schemas</h1>
<p className="text-muted-foreground mb-8">
Schemas define the structure and validation rules for your UI specs.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Schema?</h2>
<p className="text-sm text-muted-foreground mb-4">
A schema defines the JSON structure that describes your UI. It includes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Element structure</strong> — How
components are nested and referenced
</li>
<li>
<strong className="text-foreground">Property types</strong> — What
props each component accepts
</li>
<li>
<strong className="text-foreground">Data binding syntax</strong> — How
to reference dynamic data
</li>
<li>
<strong className="text-foreground">Action format</strong> — How user
interactions are defined
</li>
</ul>
<h2 className="text-xl font-semibold mt-12 mb-4">
Schema-Agnostic by Design
</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render can work with any JSON schema.{" "}
<code className="text-foreground">@json-render/core</code> provides the
primitives to define catalogs and renderers for any format:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">@json-render/react</strong> — The
built-in flat element tree schema
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/a2ui" className="hover:underline">
A2UI
</Link>
</strong>{" "}
— Google{"'"}s Agent-to-User Interaction protocol
</li>
<li>
<strong className="text-foreground">
<Link href="/docs/adaptive-cards" className="hover:underline">
Adaptive Cards
</Link>
</strong>{" "}
— Microsoft{"'"}s platform-agnostic UI format
</li>
<li>
<strong className="text-foreground">AG-UI</strong> — CopilotKit{"'"}s
Agent User Interaction Protocol
</li>
<li>
<strong className="text-foreground">OpenAPI/Swagger</strong> — API
documentation schemas for dynamic forms
</li>
<li>
<strong className="text-foreground">Custom schemas</strong> — Design
your own format tailored to your domain
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
See the{" "}
<Link
href="/docs/custom-schema"
className="text-foreground hover:underline"
>
Custom Schema guide
</Link>{" "}
to learn how to implement support for any schema.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Built-in Schema</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/react</code> uses a flat
element tree schema with a root key and elements map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["text-1", "button-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Welcome, $data.user.name" },
"children": [],
"parentKey": "card-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Click me" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema Components</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
In the built-in schema, each element in the elements map has this
structure:
</p>
<Code lang="typescript">{`interface Element {
key: string; // Unique identifier
type: string; // Component type from catalog
props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys
parentKey: string; // Parent element key (empty for root)
visible?: VisibilityRule; // Conditional display
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Data Binding Syntax</h3>
<p className="text-sm text-muted-foreground mb-4">
Reference dynamic data using the{" "}
<code className="text-foreground">$data</code> prefix in props:
</p>
<Code lang="json">{`{
"key": "greeting",
"type": "Text",
"props": {
"content": "$data.user.name",
"count": "$data.items.length"
},
"children": [],
"parentKey": "card-1"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
<p className="text-sm text-muted-foreground mb-4">
Actions are defined in the catalog and referenced from components. The
renderer handles action execution:
</p>
<Code lang="typescript">{`// In your catalog
actions: {
navigate: {
params: z.object({ url: z.string() }),
description: 'Navigate to a URL',
},
apiCall: {
params: z.object({
endpoint: z.string(),
method: z.enum(['GET', 'POST', 'PUT', 'DELETE']),
}),
description: 'Make an API request',
},
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Custom Schemas</h2>
<p className="text-sm text-muted-foreground mb-4">
<code className="text-foreground">@json-render/core</code> is
schema-agnostic. You can define any JSON structure:
</p>
<Code lang="typescript">{`import { z } from 'zod';
// Define your own element schema
const MyElementSchema = z.object({
component: z.string(),
settings: z.record(z.unknown()),
nested: z.array(z.lazy(() => MyElementSchema)).optional(),
});
// Define your own data binding format
const BoundValue = z.object({
literal: z.string().optional(),
path: z.string().optional(), // e.g., "/users/0/name"
});
// Define your own action format
const ActionSchema = z.object({
name: z.string(),
context: z.record(z.unknown()).optional(),
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Schema vs Catalog</h2>
<p className="text-sm text-muted-foreground mb-4">
The schema and catalog work together but serve different purposes:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">Schema</strong> — Defines the JSON
structure (how elements are organized)
</li>
<li>
<strong className="text-foreground">Catalog</strong> — Defines
available components and their props (what can be used)
</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
The schema is the grammar; the catalog is the vocabulary.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Next</h2>
<p className="text-sm text-muted-foreground">
Learn about{" "}
<Link href="/docs/specs" className="text-foreground hover:underline">
specs
</Link>{" "}
— the actual JSON documents that describe your UI.
</p>
</article>
);
}
+369
View File
@@ -0,0 +1,369 @@
import Link from "next/link";
import { Code } from "@/components/code";
export const metadata = {
title: "Specs | json-render",
};
export default function SpecsPage() {
return (
<article>
<h1 className="text-3xl font-bold mb-4">Specs</h1>
<p className="text-muted-foreground mb-8">
A spec is a JSON document that describes your UI.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">What is a Spec?</h2>
<p className="text-sm text-muted-foreground mb-4">
A spec (specification) is the actual JSON that describes a UI. It
conforms to a{" "}
<Link href="/docs/schemas" className="text-foreground hover:underline">
schema
</Link>{" "}
and uses components from a{" "}
<Link href="/docs/catalog" className="text-foreground hover:underline">
catalog
</Link>
. Specs can be:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>Generated by AI in real-time</li>
<li>Stored in a database</li>
<li>Streamed progressively from a server</li>
<li>Hand-authored as JSON files</li>
</ul>
<p className="text-sm text-muted-foreground mb-4">
json-render is schema-agnostic — your specs can follow any JSON
structure you choose.
</p>
<h2 className="text-xl font-semibold mt-12 mb-4">Example Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Simple Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A basic spec using the{" "}
<code className="text-foreground">@json-render/react</code> schema. Note
the flat structure with a <code className="text-foreground">root</code>{" "}
key and <code className="text-foreground">elements</code> map:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Welcome" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Hello, $data.user.name!" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Complex Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A more complex spec with multiple nested elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "User Profile", "padding": "md" },
"children": ["row-1", "button-1"],
"parentKey": ""
},
"row-1": {
"key": "row-1",
"type": "Row",
"props": { "gap": "md" },
"children": ["avatar-1", "stack-1"],
"parentKey": "card-1"
},
"avatar-1": {
"key": "avatar-1",
"type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"children": [],
"parentKey": "row-1"
},
"stack-1": {
"key": "stack-1",
"type": "Stack",
"props": { "gap": "sm" },
"children": ["name-text", "email-text"],
"parentKey": "row-1"
},
"name-text": {
"key": "name-text",
"type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" },
"children": [],
"parentKey": "stack-1"
},
"email-text": {
"key": "email-text",
"type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" },
"children": [],
"parentKey": "stack-1"
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Edit Profile" },
"children": [],
"parentKey": "card-1"
}
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Block-Level Spec</h3>
<p className="text-sm text-muted-foreground mb-4">
A high-level spec using semantic blocks for page layouts:
</p>
<Code lang="json">{`{
"root": "page",
"elements": {
"page": {
"key": "page",
"type": "Page",
"props": {},
"children": ["header", "hero", "features", "footer"],
"parentKey": ""
},
"header": {
"key": "header",
"type": "Header",
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
"children": [],
"parentKey": "page"
},
"hero": {
"key": "hero",
"type": "Hero",
"props": {
"title": "Build UIs with JSON",
"subtitle": "Let AI generate your interfaces",
"ctaLabel": "Get Started",
"ctaHref": "/docs"
},
"children": [],
"parentKey": "page"
},
"features": {
"key": "features",
"type": "Features",
"props": { "columns": 3 },
"children": ["feature-1", "feature-2", "feature-3"],
"parentKey": "page"
},
"feature-1": {
"key": "feature-1",
"type": "Feature",
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
"children": [],
"parentKey": "features"
},
"feature-2": {
"key": "feature-2",
"type": "Feature",
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
"children": [],
"parentKey": "features"
},
"feature-3": {
"key": "feature-3",
"type": "Feature",
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
"children": [],
"parentKey": "features"
},
"footer": {
"key": "footer",
"type": "Footer",
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
"children": [],
"parentKey": "page"
}
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Anatomy</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Root and Elements</h3>
<p className="text-sm text-muted-foreground mb-4">
Every spec has a <code className="text-foreground">root</code> key
pointing to the entry element, and an{" "}
<code className="text-foreground">elements</code> map containing all
elements:
</p>
<Code lang="json">{`{
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "My Card" },
"children": ["text-1"],
"parentKey": ""
},
"text-1": { ... }
}
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Element Structure</h3>
<p className="text-sm text-muted-foreground mb-4">
Each element in the map has a consistent shape:
</p>
<Code lang="json">{`{
"key": "unique-id",
"type": "ComponentName",
"props": { "label": "Hello" },
"children": ["child-1", "child-2"],
"parentKey": "parent-id"
}`}</Code>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
<li>
<code className="text-foreground">key</code> — Unique identifier for
this element
</li>
<li>
<code className="text-foreground">type</code> — Component type from
your catalog
</li>
<li>
<code className="text-foreground">props</code> — Component properties
</li>
<li>
<code className="text-foreground">children</code> — Array of child
element keys
</li>
<li>
<code className="text-foreground">parentKey</code> — Key of parent
element (empty string for root)
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
<p className="text-sm text-muted-foreground mb-4">
Props can reference data using{" "}
<code className="text-foreground">$data</code> paths:
</p>
<Code lang="json">{`{
"key": "metric-1",
"type": "Metric",
"props": {
"label": "Total Revenue",
"value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange"
},
"children": [],
"parentKey": "dashboard"
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
<p className="text-sm text-muted-foreground mb-4">
Control when elements appear using the{" "}
<code className="text-foreground">visible</code> property:
</p>
<Code lang="json">{`{
"key": "alert-1",
"type": "Alert",
"props": {
"message": "You have unsaved changes"
},
"children": [],
"parentKey": "form",
"visible": {
"path": "$data.form.isDirty",
"operator": "eq",
"value": true
}
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Working with Specs</h2>
<h3 className="text-lg font-medium mt-8 mb-3">Rendering a Spec</h3>
<Code lang="tsx">{`import { Renderer } from '@json-render/react';
function MyApp({ spec, data }) {
return (
<Renderer
spec={spec}
data={data}
registry={registry}
/>
);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Validating a Spec</h3>
<Code lang="typescript">{`import { validate } from '@json-render/core';
const result = validate(spec, catalog);
if (!result.valid) {
console.error('Invalid spec:', result.errors);
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Streaming Specs</h3>
<p className="text-sm text-muted-foreground mb-4">
Specs can be streamed incrementally for progressive rendering:
</p>
<Code lang="tsx">{`import { useUIStream } from '@json-render/react';
function GenerativeUI() {
const { spec, isStreaming } = useUIStream({
api: '/api/generate',
});
return (
<Renderer
spec={spec}
registry={registry}
loading={isStreaming}
/>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Spec Sources</h2>
<p className="text-sm text-muted-foreground mb-4">
Specs can come from various sources:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mb-4">
<li>
<strong className="text-foreground">AI Generation</strong> — LLMs
generate specs based on prompts and catalog
</li>
<li>
<strong className="text-foreground">Database</strong> — Store specs as
JSON and load dynamically
</li>
<li>
<strong className="text-foreground">API Response</strong> — Server
returns specs based on user/context
</li>
<li>
<strong className="text-foreground">Static Files</strong> — Pre-built
specs for known UI patterns
</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/catalog" className="text-foreground hover:underline">
catalogs
</Link>{" "}
— the vocabulary of components available in your specs.
</p>
</article>
);
}
+175
View File
@@ -0,0 +1,175 @@
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">SpecStream Format</h2>
<p className="text-sm text-muted-foreground mb-4">
json-render uses <strong>SpecStream</strong>, a JSONL-based streaming
format where each line is a JSON patch operation that progressively
builds your spec:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"set","path":"/root/children/1","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 {
spec, // Current UI spec state
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
abort, // Function to cancel streaming
} = useUIStream({
api: '/api/generate',
onChunk: (chunk) => {}, // Optional: called for each chunk
onFinish: (spec) => {}, // Optional: called when complete
});
}`}</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">{`import { streamText } from 'ai';
import { catalog } from '@/lib/catalog';
export async function POST(req: Request) {
const { prompt } = await req.json();
const result = streamText({
model: 'anthropic/claude-haiku-4.5',
system: catalog.prompt(),
prompt,
});
// Return as a streaming response
return result.toTextStreamResponse();
}`}</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 spec changes:
</p>
<Code lang="tsx">{`function App() {
const { spec, isStreaming } = useUIStream({ api: '/api/generate' });
return (
<div>
{isStreaming && <LoadingIndicator />}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<Code lang="tsx">{`function App() {
const { isStreaming, send, abort } = useUIStream({
api: '/api/generate',
});
return (
<div>
<button onClick={() => send('Create dashboard')}>
Generate
</button>
{isStreaming && (
<button onClick={abort}>Cancel</button>
)}
</div>
);
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
Low-Level SpecStream API
</h2>
<p className="text-sm text-muted-foreground mb-4">
For custom streaming implementations, use the SpecStream compiler
directly:
</p>
<Code lang="typescript">{`import { createSpecStreamCompiler } from '@json-render/core';
// Create a compiler for your spec type
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks from AI
async function processStream(reader: ReadableStreamDefaultReader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const { result, newPatches } = compiler.push(value);
if (newPatches.length > 0) {
// Update UI with partial result
setSpec(result);
}
}
// Get final compiled result
return compiler.getResult();
}`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">One-Shot Compilation</h3>
<p className="text-sm text-muted-foreground mb-4">
For non-streaming scenarios, compile entire SpecStream at once:
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
</article>
);
}
@@ -0,0 +1,189 @@
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">{`import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react';
import { z } from 'zod';
const catalog = defineCatalog(schema, {
components: { /* ... */ },
functions: {
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({ props }) {
const { value, setValue, errors, validate } = useFieldValidation(
props.valuePath,
props.checks
);
return (
<div>
<label>{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>
);
}
@@ -0,0 +1,148 @@
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';
// The Renderer handles visibility automatically, but you can also use the hook
function ConditionalContent({ condition, children }) {
const isVisible = useIsVisible(condition);
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>
);
}
-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>
);
}
+8 -1
View File
@@ -1,7 +1,14 @@
import { Header } from "@/components/header";
export default function MainLayout({
children,
}: {
children: React.ReactNode;
}) {
return <main className="min-h-[calc(100dvh-4rem)]">{children}</main>;
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
</div>
);
}
+36 -40
View File
@@ -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">
<h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
AI → json-render → UI
</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.
Define a component catalog. Users prompt. AI outputs JSON constrained
to your catalog. Your components render it.
</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}>
@@ -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 },
);
}
+37 -113
View File
@@ -1,77 +1,27 @@
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";
import { playgroundCatalog } from "@/lib/catalog";
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 = playgroundCatalog.prompt({
customRules: [
"For forms: Card should be the root element, not wrapped in a centering Stack",
"NEVER use viewport height classes (min-h-screen, h-screen) - breaks the container",
"NEVER use page background colors (bg-gray-50) - container has its own background",
],
});
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
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,
});
}
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(),
});
}
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");
}
export async function POST(req: Request) {
// Get client IP for rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
// Check rate limits (minute and daily)
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
@@ -93,64 +43,38 @@ export async function POST(req: Request) {
);
}
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 { prompt, context } = await req.json();
const previousSpec = context?.previousSpec;
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);
// Build the user prompt, including previous tree for iteration
let userPrompt = sanitizedPrompt;
if (
previousSpec &&
previousSpec.root &&
Object.keys(previousSpec.elements || {}).length > 0
) {
userPrompt = `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):
${JSON.stringify(previousSpec, null, 2)}
USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
}
const result = streamText({
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
abortSignal: req.signal,
system: [
{
role: "system",
content: systemPrompt,
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
],
system: SYSTEM_PROMPT,
prompt: userPrompt,
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();
}
-7
View File
@@ -1,7 +0,0 @@
import { createMcpRoute } from "@vercel/geistdocs/routes/mcp";
import { config } from "@/lib/geistdocs/config";
import { GET as search } from "../search/route";
const handler = createMcpRoute({ config, search });
export { handler as GET, handler as POST };
-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" } },
);
}
+19 -101
View File
@@ -1,16 +1,7 @@
@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;
@@ -35,10 +26,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 {
.dark {
--ds-gray-500: oklch(0.39 0 0);
/* Monochrome dark theme */
--background: oklch(0.0 0 0);
@@ -60,7 +50,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 +107,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 +137,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;
}
+14 -63
View File
@@ -1,18 +1,9 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
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 +15,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,80 +37,44 @@ 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}`}
>
<ThemeProvider>
<DocsProvider>
<Navbar config={config} />
{children}
<Footer />
</DocsProvider>
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
</ThemeProvider>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>{children}</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
-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,
},
],
},
);
}
+1 -5
View File
@@ -3,9 +3,5 @@ export default function PlaygroundLayout({
}: {
children: React.ReactNode;
}) {
return (
<main className="h-[calc(100dvh-4rem)] flex flex-col overflow-hidden">
{children}
</main>
);
return <div className="h-dvh flex flex-col overflow-hidden">{children}</div>;
}
+3 -2
View File
@@ -1,7 +1,8 @@
import { Playground } from "@/components/playground";
import { pageMetadata } from "@/lib/page-metadata";
export const metadata = pageMetadata("playground");
export const metadata = {
title: "Playground | json-render",
};
export default function PlaygroundPage() {
return <Playground />;
-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}`,
}));
}
+2 -2
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;
}
-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>
);
}
+112 -338
View File
@@ -14,149 +14,145 @@ 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 { PlaygroundRenderer } from "@/lib/renderer";
const SIMULATION_PROMPT = "Show a team performance dashboard";
const SIMULATION_PROMPT = "Create a contact form with name, email, and message";
interface SimulationStage {
tree: Spec;
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 {
@@ -164,65 +160,11 @@ interface DemoProps {
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",
"Create a login form with email and password",
"Build a feedback form with rating stars",
"Design a contact card with avatar",
"Make a settings panel with toggles",
];
export function Demo({
@@ -252,15 +194,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(() => {
@@ -280,7 +213,6 @@ export function Demo({
isStreaming,
send,
clear,
rawLines: apiRawLines,
} = useUIStream({
api: "/api/generate",
onError: (err: Error) => {
@@ -353,12 +285,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" && apiSpec) {
// Convert tree to stream line for display
const streamLine = JSON.stringify({ tree: apiSpec });
if (
!streamLines.includes(streamLine) &&
Object.keys(apiSpec.elements).length > 0
) {
setStreamLines((prev) => {
const lastLine = prev[prev.length - 1];
if (lastLine !== streamLine) {
return [...prev, streamLine];
}
return prev;
});
}
}
}, [mode, apiRawLines]);
}, [mode, apiSpec, streamLines]);
const handleSubmit = useCallback(async () => {
if (!userPrompt.trim() || isStreaming) return;
@@ -370,11 +315,6 @@ export function Demo({
? 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 +342,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));
}
@@ -999,13 +915,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
setMode("interactive");
setPhase("complete");
setUserPrompt(prompt);
setTimeout(() => {
const el = inputRef.current;
if (el) {
el.focus();
el.setSelectionRange(prompt.length, prompt.length);
}
}, 0);
setTimeout(() => inputRef.current?.focus(), 0);
}, []);
return (
@@ -1134,7 +1044,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
{/* 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) => (
{(["json", "stream"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
@@ -1149,22 +1059,16 @@ Open [http://localhost:3000](http://localhost:3000) to view.
))}
</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]"}`}
className={`border border-border rounded bg-background font-mono text-xs text-left grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
{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="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"}`}
>
@@ -1200,136 +1104,6 @@ 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>
@@ -1389,7 +1163,7 @@ Open [http://localhost:3000](http://localhost:3000) to view.
</div>
</div>
<div
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[36rem]"}`}
className={`border border-border rounded bg-background grid relative group ${fullscreen ? "flex-1 min-h-0" : "h-[28rem]"}`}
>
{renderView === "static" && (
<div className="absolute top-2 right-2 z-10">
-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>
)}
</>
);
}
@@ -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>
);
}
+41 -122
View File
@@ -1,65 +1,17 @@
"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;
}) {
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 }) {
export function Header() {
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");
}
@@ -105,86 +57,53 @@ export function Header({ stars }: { stars?: string }) {
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
<span className="font-medium tracking-tight text-lg">
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",
)}
<nav className="flex items-center gap-4">
<Link
href="/playground"
className={cn(
"text-sm transition-colors",
isActive("/playground")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
<span className="sm:hidden">Play</span>
<span className="hidden sm:inline">Playground</span>
</Link>
<Link
href="/docs"
className={cn(
"text-sm transition-colors",
isActive("/docs")
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground",
)}
>
Docs
</Link>
<a
href="https://github.com/vercel-labs/json-render"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<svg
viewBox="0 0 16 16"
className="h-4 w-4"
fill="currentColor"
aria-hidden="true"
>
{link.label}
</Link>
))}
<Search />
<GitHubLink stars={stars} />
<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>
<span>10k</span>
</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 };
+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,
};
-33
View File
@@ -1,33 +0,0 @@
"use client";
import { Collapsible as CollapsiblePrimitive } from "radix-ui";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
-158
View File
@@ -1,158 +0,0 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean;
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};
-135
View File
@@ -1,135 +0,0 @@
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className,
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className,
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
-257
View File
@@ -1,257 +0,0 @@
"use client";
import * as React from "react";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className,
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className,
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className,
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};
-127
View File
@@ -1,127 +0,0 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants, type Button } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className,
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};
-89
View File
@@ -1,89 +0,0 @@
"use client";
import * as React from "react";
import { Popover as PopoverPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-1 text-sm", className)}
{...props}
/>
);
}
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
return (
<div
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
);
}
function PopoverDescription({
className,
...props
}: React.ComponentProps<"p">) {
return (
<p
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
);
}
export {
Popover,
PopoverTrigger,
PopoverContent,
PopoverAnchor,
PopoverHeader,
PopoverTitle,
PopoverDescription,
};
+5 -14
View File
@@ -18,7 +18,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/75 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:duration-150 data-[state=open]:duration-150",
className,
)}
{...props}
@@ -27,26 +27,17 @@ const SheetOverlay = React.forwardRef<
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetSideVariants = {
left: "inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
right:
"inset-y-0 right-0 h-full w-3/4 max-w-xs data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right",
};
const SheetContent = React.forwardRef<
React.ComponentRef<typeof SheetPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
side?: "left" | "right";
overlayClassName?: string;
}
>(({ className, children, side = "left", overlayClassName, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay className={overlayClassName} />
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-150 data-[state=open]:duration-150 data-[state=open]:animate-in data-[state=closed]:animate-out focus:outline-none",
sheetSideVariants[side],
"inset-y-0 left-0 h-full w-3/4 max-w-xs border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left",
className,
)}
{...props}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };
-63
View File
@@ -1,63 +0,0 @@
"use client";
import * as React from "react";
import { Slider as SliderPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max],
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className,
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
+2 -16
View File
@@ -1,29 +1,15 @@
"use client";
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const { resolvedTheme } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
-116
View File
@@ -1,116 +0,0 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
-83
View File
@@ -1,83 +0,0 @@
"use client";
import * as React from "react";
import { type VariantProps } from "class-variance-authority";
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number;
}
>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
-47
View File
@@ -1,47 +0,0 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Toggle as TogglePrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
-57
View File
@@ -1,57 +0,0 @@
"use client";
import * as React from "react";
import { Tooltip as TooltipPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />;
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
-407
View File
@@ -1,407 +0,0 @@
---
title: "Adaptive Cards Integration"
---
Use json-render to render [Microsoft Adaptive Cards](https://adaptivecards.io) natively.
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can support Adaptive Cards. The examples are illustrative and may require adaptation for production use.
</p>
</div>
## Adaptive Cards Overview
Adaptive Cards is a JSON-based format for platform-agnostic UI snippets. Cards have a `body` array of elements and an optional `actions` array for interactive buttons.
### Example Adaptive Card
```json
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"text": "Hello, Adaptive Cards!",
"size": "large",
"weight": "bolder"
},
{
"type": "Image",
"url": "https://example.com/image.png",
"altText": "Example image"
},
{
"type": "Container",
"items": [
{
"type": "TextBlock",
"text": "This is inside a container",
"wrap": true
}
]
},
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"width": "auto",
"items": [
{ "type": "TextBlock", "text": "Column 1" }
]
},
{
"type": "Column",
"width": "stretch",
"items": [
{ "type": "TextBlock", "text": "Column 2" }
]
}
]
},
{
"type": "Input.Text",
"id": "userInput",
"placeholder": "Enter your name",
"label": "Name"
}
],
"actions": [
{
"type": "Action.Submit",
"title": "Submit"
},
{
"type": "Action.OpenUrl",
"title": "Learn More",
"url": "https://adaptivecards.io"
}
]
}
```
## Creating an Adaptive Cards Catalog
Define a catalog matching the Adaptive Cards element types:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
// Common Adaptive Cards properties
const Spacing = z.enum(['none', 'small', 'default', 'medium', 'large', 'extraLarge', 'padding']);
const HorizontalAlignment = z.enum(['left', 'center', 'right']);
const VerticalAlignment = z.enum(['top', 'center', 'bottom']);
const FontSize = z.enum(['small', 'default', 'medium', 'large', 'extraLarge']);
const FontWeight = z.enum(['lighter', 'default', 'bolder']);
const ImageSize = z.enum(['auto', 'stretch', 'small', 'medium', 'large']);
const ImageStyle = z.enum(['default', 'person']);
// Base element properties shared by most elements
const BaseElement = {
id: z.string().optional(),
isVisible: z.boolean().optional(),
separator: z.boolean().optional(),
spacing: Spacing.optional(),
};
export const adaptiveCardsCatalog = defineCatalog(schema, {
components: {
// Root card
AdaptiveCard: {
description: 'Root Adaptive Card container',
props: z.object({
version: z.string(),
body: z.array(z.unknown()).optional(),
actions: z.array(z.unknown()).optional(),
fallbackText: z.string().optional(),
minHeight: z.string().optional(),
rtl: z.boolean().optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
// Elements
TextBlock: {
description: 'Displays text with formatting options',
props: z.object({
...BaseElement,
text: z.string(),
color: z.enum(['default', 'dark', 'light', 'accent', 'good', 'warning', 'attention']).optional(),
fontType: z.enum(['default', 'monospace']).optional(),
horizontalAlignment: HorizontalAlignment.optional(),
isSubtle: z.boolean().optional(),
maxLines: z.number().optional(),
size: FontSize.optional(),
weight: FontWeight.optional(),
wrap: z.boolean().optional(),
}),
},
Image: {
description: 'Displays an image',
props: z.object({
...BaseElement,
url: z.string(),
altText: z.string().optional(),
backgroundColor: z.string().optional(),
height: z.string().optional(),
width: z.string().optional(),
horizontalAlignment: HorizontalAlignment.optional(),
size: ImageSize.optional(),
style: ImageStyle.optional(),
}),
},
Container: {
description: 'Groups elements together',
props: z.object({
...BaseElement,
items: z.array(z.unknown()),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
bleed: z.boolean().optional(),
minHeight: z.string().optional(),
}),
},
ColumnSet: {
description: 'Arranges columns horizontally',
props: z.object({
...BaseElement,
columns: z.array(z.unknown()),
horizontalAlignment: HorizontalAlignment.optional(),
minHeight: z.string().optional(),
}),
},
Column: {
description: 'A column within a ColumnSet',
props: z.object({
...BaseElement,
items: z.array(z.unknown()).optional(),
width: z.union([z.string(), z.number()]).optional(),
style: z.enum(['default', 'emphasis', 'good', 'attention', 'warning', 'accent']).optional(),
verticalContentAlignment: VerticalAlignment.optional(),
}),
},
FactSet: {
description: 'Displays a series of facts as key/value pairs',
props: z.object({
...BaseElement,
facts: z.array(z.object({
title: z.string(),
value: z.string(),
})),
}),
},
// Inputs
'Input.Text': {
description: 'Text input field',
props: z.object({
...BaseElement,
id: z.string(),
isMultiline: z.boolean().optional(),
maxLength: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.string().optional(),
style: z.enum(['text', 'tel', 'url', 'email', 'password']).optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Number': {
description: 'Number input field',
props: z.object({
...BaseElement,
id: z.string(),
max: z.number().optional(),
min: z.number().optional(),
placeholder: z.string().optional(),
label: z.string().optional(),
value: z.number().optional(),
isRequired: z.boolean().optional(),
errorMessage: z.string().optional(),
}),
},
'Input.Toggle': {
description: 'Toggle/checkbox input',
props: z.object({
...BaseElement,
id: z.string(),
title: z.string(),
label: z.string().optional(),
value: z.string().optional(),
valueOff: z.string().optional(),
valueOn: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
'Input.ChoiceSet': {
description: 'Dropdown or radio/checkbox group',
props: z.object({
...BaseElement,
id: z.string(),
choices: z.array(z.object({
title: z.string(),
value: z.string(),
})),
isMultiSelect: z.boolean().optional(),
style: z.enum(['compact', 'expanded']).optional(),
label: z.string().optional(),
value: z.string().optional(),
placeholder: z.string().optional(),
isRequired: z.boolean().optional(),
}),
},
// Actions
'Action.OpenUrl': {
description: 'Opens a URL',
props: z.object({
title: z.string().optional(),
url: z.string(),
iconUrl: z.string().optional(),
}),
},
'Action.Submit': {
description: 'Submits input data',
props: z.object({
title: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
'Action.ShowCard': {
description: 'Shows a card inline',
props: z.object({
title: z.string().optional(),
card: z.unknown(),
iconUrl: z.string().optional(),
}),
},
'Action.Execute': {
description: 'Universal action for bots',
props: z.object({
title: z.string().optional(),
verb: z.string().optional(),
data: z.unknown().optional(),
iconUrl: z.string().optional(),
}),
},
},
});
```
## Building an Adaptive Cards Renderer
Create a renderer that processes Adaptive Cards JSON. See the [A2UI integration](/docs/a2ui) page for a similar pattern. The key is mapping each Adaptive Card element type to a React component, resolving nested `items` and `columns` arrays recursively.
## Usage Example
Render an Adaptive Card and handle actions:
```tsx
'use client';
import { AdaptiveCardRenderer } from './adaptive-card-renderer';
const card = {
type: 'AdaptiveCard' as const,
version: '1.5',
body: [
{
type: 'TextBlock',
text: 'Contact Form',
size: 'large',
weight: 'bolder',
},
{
type: 'Input.Text',
id: 'name',
label: 'Your Name',
placeholder: 'Enter your name',
},
{
type: 'Input.Text',
id: 'message',
label: 'Message',
placeholder: 'Enter your message',
isMultiline: true,
},
],
actions: [
{
type: 'Action.Submit',
title: 'Send',
data: { action: 'submitForm' },
},
],
};
export function ContactCard() {
const handleAction = (action: any, inputData: Record<string, unknown>) => {
console.log('Action:', action);
console.log('Input data:', inputData);
// Send to your backend
fetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: inputData }),
});
};
return <AdaptiveCardRenderer card={card} onAction={handleAction} />;
}
```
## Handling Action.Execute for Bots
For bot scenarios, handle `Action.Execute` with the verb and data:
```typescript
interface ActionExecutePayload {
action: {
type: 'Action.Execute';
verb: string;
data?: unknown;
};
inputs: Record<string, unknown>;
}
async function handleBotAction(payload: ActionExecutePayload) {
const response = await fetch('/api/bot/action', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
verb: payload.action.verb,
data: payload.action.data,
inputs: payload.inputs,
}),
});
// Bot may return a new card to render
const result = await response.json();
if (result.card) {
return result.card; // New AdaptiveCard to render
}
}
```
## Next
Learn about [A2UI integration](/docs/a2ui) for another agent-driven UI protocol.
-384
View File
@@ -1,384 +0,0 @@
---
title: "AG-UI Integration"
---
Use json-render to support [AG-UI](https://docs.copilotkit.ai/ag-ui) (Agent User Interaction Protocol) from CopilotKit.
<div className="rounded-lg border border-amber-500/50 bg-amber-500/10 p-4 mb-8">
<p className="text-sm text-amber-700 dark:text-amber-300">
<strong>Concept:</strong> This page demonstrates how json-render can support AG-UI. The examples are illustrative and may require adaptation for production use.
</p>
</div>
## What is AG-UI?
AG-UI is an open protocol for connecting AI agents to user interfaces. It provides a standardized way for agents to render UI components, handle user input, and manage state. The protocol uses events streamed over HTTP to update the UI in real-time.
## AG-UI Event Types
AG-UI defines several event types for agent-UI communication:
- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` — Streaming text messages
- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` — Tool/function calls
- `STATE_SNAPSHOT` / `STATE_DELTA` — State updates
- `CUSTOM` — Custom events for UI rendering
### Example AG-UI Event Stream
```json
{"type": "RUN_STARTED", "threadId": "thread-123", "runId": "run-456"}
{"type": "TEXT_MESSAGE_START", "messageId": "msg-1", "role": "assistant"}
{"type": "TEXT_MESSAGE_CONTENT", "messageId": "msg-1", "delta": "Here's a dashboard for you:"}
{"type": "TEXT_MESSAGE_END", "messageId": "msg-1"}
{"type": "TOOL_CALL_START", "toolCallId": "tc-1", "toolCallName": "render_ui"}
{"type": "TOOL_CALL_ARGS", "toolCallId": "tc-1", "delta": "{\"component\": \"Dashboard\", \"props\": {\"title\": \"Sales\"}}"}
{"type": "TOOL_CALL_END", "toolCallId": "tc-1"}
{"type": "RUN_FINISHED"}
```
## Define the AG-UI Schema
Define schemas for AG-UI event types:
```typescript
import { z } from 'zod';
// Base event schema
const BaseEvent = z.object({
type: z.string(),
timestamp: z.number().optional(),
});
// Text message events
const TextMessageStart = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_START'),
messageId: z.string(),
role: z.enum(['user', 'assistant']),
});
const TextMessageContent = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_CONTENT'),
messageId: z.string(),
delta: z.string(),
});
const TextMessageEnd = BaseEvent.extend({
type: z.literal('TEXT_MESSAGE_END'),
messageId: z.string(),
});
// Tool call events
const ToolCallStart = BaseEvent.extend({
type: z.literal('TOOL_CALL_START'),
toolCallId: z.string(),
toolCallName: z.string(),
parentMessageId: z.string().optional(),
});
const ToolCallArgs = BaseEvent.extend({
type: z.literal('TOOL_CALL_ARGS'),
toolCallId: z.string(),
delta: z.string(),
});
const ToolCallEnd = BaseEvent.extend({
type: z.literal('TOOL_CALL_END'),
toolCallId: z.string(),
});
// State events
const StateSnapshot = BaseEvent.extend({
type: z.literal('STATE_SNAPSHOT'),
snapshot: z.record(z.unknown()),
});
const StateDelta = BaseEvent.extend({
type: z.literal('STATE_DELTA'),
delta: z.array(z.object({
op: z.enum(['add', 'remove', 'replace']),
path: z.string(),
value: z.unknown().optional(),
})),
});
// Custom event for UI components
const CustomEvent = BaseEvent.extend({
type: z.literal('CUSTOM'),
name: z.string(),
value: z.unknown(),
});
// Run lifecycle events
const RunStarted = BaseEvent.extend({
type: z.literal('RUN_STARTED'),
threadId: z.string(),
runId: z.string(),
});
const RunFinished = BaseEvent.extend({
type: z.literal('RUN_FINISHED'),
});
const RunError = BaseEvent.extend({
type: z.literal('RUN_ERROR'),
message: z.string(),
code: z.string().optional(),
});
// Union of all events
export const AGUIEvent = z.discriminatedUnion('type', [
TextMessageStart,
TextMessageContent,
TextMessageEnd,
ToolCallStart,
ToolCallArgs,
ToolCallEnd,
StateSnapshot,
StateDelta,
CustomEvent,
RunStarted,
RunFinished,
RunError,
]);
export type AGUIEvent = z.infer<typeof AGUIEvent>;
```
## Define the AG-UI Catalog
Create a catalog for UI components that agents can render:
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
import { z } from 'zod';
export const aguiCatalog = defineCatalog(schema, {
components: {
Container: {
description: 'A container for grouping elements',
props: z.object({
direction: z.enum(['row', 'column']).optional(),
gap: z.enum(['none', 'sm', 'md', 'lg']).optional(),
padding: z.enum(['none', 'sm', 'md', 'lg']).optional(),
}),
},
Card: {
description: 'A card with optional title',
props: z.object({
title: z.string().optional(),
description: z.string().optional(),
}),
},
Text: {
description: 'Text content',
props: z.object({
content: z.string(),
variant: z.enum(['body', 'heading', 'caption', 'code']).optional(),
}),
},
Metric: {
description: 'Displays a metric value',
props: z.object({
label: z.string(),
value: z.union([z.string(), z.number()]),
change: z.number().optional(),
format: z.enum(['number', 'currency', 'percent']).optional(),
}),
},
Button: {
description: 'Interactive button',
props: z.object({
label: z.string(),
variant: z.enum(['primary', 'secondary', 'outline', 'ghost']).optional(),
disabled: z.boolean().optional(),
}),
},
Alert: {
description: 'Alert message',
props: z.object({
message: z.string(),
type: z.enum(['info', 'success', 'warning', 'error']).optional(),
}),
},
// Add more components...
},
actions: {
submit: {
description: 'Submit form data',
params: z.object({ formId: z.string() }),
},
navigate: {
description: 'Navigate to a URL',
params: z.object({ url: z.string() }),
},
callback: {
description: 'Trigger a callback to the agent',
params: z.object({
name: z.string(),
data: z.record(z.unknown()).optional(),
}),
},
},
});
```
## Build an AG-UI Event Processor
Process AG-UI events and render UI components:
```tsx
'use client';
import React, { useState, useCallback } from 'react';
import { AGUIEvent } from './schema';
interface AGUIState {
messages: Array<{
id: string;
role: 'user' | 'assistant';
content: string;
}>;
toolCalls: Map<string, {
name: string;
args: string;
result?: unknown;
}>;
state: Record<string, unknown>;
isRunning: boolean;
}
export function useAGUI() {
const [aguiState, setAGUIState] = useState<AGUIState>({
messages: [],
toolCalls: new Map(),
state: {},
isRunning: false,
});
const processEvent = useCallback((event: AGUIEvent) => {
switch (event.type) {
case 'RUN_STARTED':
setAGUIState(prev => ({ ...prev, isRunning: true }));
break;
case 'RUN_FINISHED':
setAGUIState(prev => ({ ...prev, isRunning: false }));
break;
case 'TEXT_MESSAGE_START':
setAGUIState(prev => ({
...prev,
messages: [...prev.messages, {
id: event.messageId,
role: event.role,
content: '',
}],
}));
break;
case 'TEXT_MESSAGE_CONTENT':
setAGUIState(prev => ({
...prev,
messages: prev.messages.map(msg =>
msg.id === event.messageId
? { ...msg, content: msg.content + event.delta }
: msg
),
}));
break;
case 'TOOL_CALL_START':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
toolCalls.set(event.toolCallId, { name: event.toolCallName, args: '' });
return { ...prev, toolCalls };
});
break;
case 'TOOL_CALL_ARGS':
setAGUIState(prev => {
const toolCalls = new Map(prev.toolCalls);
const tc = toolCalls.get(event.toolCallId);
if (tc) {
toolCalls.set(event.toolCallId, { ...tc, args: tc.args + event.delta });
}
return { ...prev, toolCalls };
});
break;
case 'STATE_SNAPSHOT':
setAGUIState(prev => ({ ...prev, state: event.snapshot }));
break;
}
}, []);
return { state: aguiState, processEvent };
}
```
## Usage Example
```tsx
'use client';
import { useAGUI } from './use-agui';
import { renderToolCallUI } from './renderer';
export function AGUIChat() {
const { state, processEvent } = useAGUI();
async function startRun(prompt: string) {
const response = await fetch('/api/agent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = response.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder.decode(value).split('\n').filter(Boolean);
for (const line of lines) {
const event = JSON.parse(line);
processEvent(event);
}
}
}
return (
<div className="space-y-4">
{state.messages.map(msg => (
<div key={msg.id} className={`p-3 rounded ${
msg.role === 'assistant' ? 'bg-muted' : 'bg-primary/10'
}`}>
{msg.content}
</div>
))}
{Array.from(state.toolCalls.values()).map((tc, i) => (
<div key={i}>{renderToolCallUI(tc)}</div>
))}
<form onSubmit={(e) => {
e.preventDefault();
const input = e.currentTarget.querySelector('input');
if (input?.value) {
startRun(input.value);
input.value = '';
}
}}>
<input
type="text"
placeholder="Ask the agent..."
className="w-full px-4 py-2 border rounded"
disabled={state.isRunning}
/>
</form>
</div>
);
}
```
## Next
Learn about [OpenAPI integration](/docs/openapi) for rendering forms from API schemas.
-238
View File
@@ -1,238 +0,0 @@
---
title: "AI SDK Integration"
---
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Standalone** (standalone UI) and **Inline** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
## Installation
```bash
npm install ai @ai-sdk/react
```
## Standalone Mode
In standalone mode, the AI outputs only JSONL patches. The entire response is a UI spec with no prose. This is the default mode and is ideal for playgrounds, builders, and dashboard generators.
### API Route
```typescript
// app/api/generate/route.ts
import { streamText } from "ai";
import { catalog } from "@/lib/catalog";
export async function POST(req: Request) {
const { prompt, currentTree } = await req.json();
const systemPrompt = catalog.prompt();
// Optionally include current UI state for context
const contextPrompt = currentTree
? `\n\nCurrent UI state:\n${JSON.stringify(currentTree, null, 2)}`
: "";
const result = streamText({
model: yourModel,
system: systemPrompt + contextPrompt,
prompt,
});
return result.toTextStreamResponse();
}
```
### Client
Use `useUIStream` on the client to compile the JSONL stream into a spec:
```tsx
"use client";
import { useUIStream, Renderer } from "@json-render/react";
function GenerativeUI() {
const { spec, isStreaming, error, send } = useUIStream({
api: "/api/generate",
});
return (
<div>
<button
onClick={() => send("Create a dashboard with metrics")}
disabled={isStreaming}
>
{isStreaming ? "Generating..." : "Generate"}
</button>
{error && <p className="text-red-500">{error.message}</p>}
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
);
}
```
## Inline Mode
In inline mode, the AI responds conversationally and includes JSONL patches inline. Text-only replies are allowed when no UI is needed. This is ideal for chatbots, copilots, and educational assistants.
### API Route
Use `pipeJsonRender` to separate text from JSONL patches in the stream. Patches are emitted as data parts that the client can pick up.
```typescript
// app/api/chat/route.ts
import { streamText } from "ai";
import { pipeJsonRender } from "@json-render/core";
import {
createUIMessageStream,
createUIMessageStreamResponse,
} from "ai";
import { catalog } from "@/lib/catalog";
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: yourModel,
system: catalog.prompt({ mode: "inline" }),
messages,
});
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
}
```
### Client
Use `useChat` from the AI SDK and `useJsonRenderMessage` from json-render to extract the spec from each message:
```tsx
"use client";
import { useChat } from "@ai-sdk/react";
import { useJsonRenderMessage, Renderer } from "@json-render/react";
function Chat() {
const { messages, input, handleInputChange, handleSubmit } = useChat({
api: "/api/chat",
});
return (
<div>
<div>
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<form onSubmit={handleSubmit}>
<input
value={input}
onChange={handleInputChange}
placeholder="Ask something..."
/>
<button type="submit">Send</button>
</form>
</div>
);
}
function ChatMessage({ message }: { message: { parts: Array<{ type: string; text?: string; data?: unknown }> } }) {
const { spec, text, hasSpec } = useJsonRenderMessage(message.parts);
return (
<div>
{text && <p>{text}</p>}
{hasSpec && spec && (
<Renderer spec={spec} registry={registry} />
)}
</div>
);
}
```
## Prompt Engineering
The `catalog.prompt()` method creates an optimized system prompt that:
- Lists all available components and their props
- Describes available actions
- Specifies the expected output format (JSONL-only or text + JSONL depending on mode)
- Includes examples for better generation
### Custom Rules
Pass custom rules to tailor AI behavior:
```typescript
const systemPrompt = catalog.prompt({
customRules: [
"Always use Card components for grouping related content",
"Prefer horizontal layouts (Row) for metrics",
"Use consistent spacing with padding=\"md\"",
],
});
```
### Inline Mode Prompt
```typescript
const inlinePrompt = catalog.prompt({ mode: "inline" });
```
In inline mode, the prompt instructs the AI to respond conversationally first, then include JSONL patches on their own lines when UI is needed. Text-only replies are allowed.
## Which Mode?
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Standalone</th>
<th>Inline</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"inline\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
Learn more in the [Generation Modes](/docs/generation-modes) guide.
## Next
- Learn about [progressive streaming](/docs/streaming)
- See the [chat example](https://github.com/vercel-labs/json-render/tree/main/examples/chat) for a complete implementation
-141
View File
@@ -1,141 +0,0 @@
---
title: "@json-render/codegen"
---
Utilities for generating code from UI trees.
## Tree Traversal
### traverseSpec
Walk the UI spec depth-first.
```typescript
function traverseSpec(
spec: Spec,
visitor: TreeVisitor,
startKey?: string
): void
interface TreeVisitor {
(element: UIElement, key: string, depth: number, parent: UIElement | null): void;
}
```
### collectUsedComponents
Get all unique component types used in a spec.
```typescript
function collectUsedComponents(spec: Spec): Set<string>
// Example
const components = collectUsedComponents(spec);
// Set { 'Card', 'Metric', 'Chart' }
```
### collectStatePaths
Get all state paths referenced in props (statePath, bindPath, etc.).
```typescript
function collectStatePaths(spec: Spec): Set<string>
// Example
const paths = collectStatePaths(spec);
// Set { 'analytics/revenue', 'analytics/customers' }
```
### collectActions
Get all action names used in the spec.
```typescript
function collectActions(spec: Spec): Set<string>
// Example
const actions = collectActions(spec);
// Set { 'submit_form', 'refresh_data' }
```
## Serialization
### serializePropValue
Serialize a single value to a code string.
```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({ $state: '/user/name' })
// { value: '{ $state: "/user/name" }', needsBraces: true }
```
### serializeProps
Serialize a props object to a JSX attributes string.
```typescript
function serializeProps(
props: Record<string, unknown>,
options?: SerializeOptions
): string
// Example
serializeProps({ title: 'Dashboard', columns: 3, disabled: true })
// 'title="Dashboard" columns={3} disabled'
```
### escapeString
Escape a string for use in code.
```typescript
function escapeString(
str: string,
quotes?: 'single' | 'double'
): string
```
## Types
### GeneratedFile
```typescript
interface GeneratedFile {
/** File path relative to project root */
path: string;
/** File contents */
content: string;
}
```
### CodeGenerator
```typescript
interface CodeGenerator {
/** Generate files from a UI spec */
generate(spec: Spec): GeneratedFile[];
}
```
### SerializeOptions
```typescript
interface SerializeOptions {
/** Quote style for strings */
quotes?: 'single' | 'double';
/** Indent for objects/arrays */
indent?: number;
}
```
-795
View File
@@ -1,795 +0,0 @@
---
title: "@json-render/core"
---
Core types, schemas, and utilities.
## experimental_composeSpec
**Experimental, unreleased:** may change in any release. Pin exact versions when published; see [source-build setup and the full Jev guide](/docs/jev).
```typescript
import {
experimental_composeSpec,
type Experimental_CompositionCandidate,
type Experimental_CompositionEvaluator,
type Experimental_CompositionEvent,
} from "@json-render/core";
const events = experimental_composeSpec({
catalog, // Standard flat Spec catalog
candidates, // App-owned atomic elements
prompt, // User request
evaluate, // Experimental_CompositionEvaluator
initialState: {}, // Included in spec; not sent to evaluator
initialSpec, // Optional selected version to edit; never mutated
elementDescriptions: {}, // Optional descriptions of existing element IDs
context: {}, // Explicitly shared evaluator context
strategy: "batch", // Default for new trees; edits are sequential
maxElements: 32, // Batched creation only, includes the root
maxSteps: 32, // Evaluation calls, including terminal decisions
maxDepth: 8, // Root depth is one
signal, // AbortSignal, optional
instructions: { root: "", next: "", parent: "" }, // Appended guidance
});
```
A candidate has `id`, `description`, `element`, optional `root` (default true), `maxUses` (default one), and `resource` (mutually exclusive variants). IDs start with a letter and contain only letters, digits, underscores, and hyphens. IDs must be unique; `finish` and `unavailable` are reserved. Elements accept `type`, `props`, optional `on`, and `visible`. The composer supplies children and named slots.
Events are full detached snapshots. A `step` contains `spec` and `step`; `complete` contains nullable `spec`, `steps`, `elapsedMs`, nullable `inputTokens`, and `stopReason` (`finish`, `limit`, or `unavailable`). Each trace step contains `index`, `choice`, `description`, nullable `parent`/`slot`, nullable `confidence`/`parentConfidence`, `elapsedMs`, and nullable `inputTokens`. Batched steps also contain `answers`, keyed by question name, with each selected `choice` and optional confidence. Each trace represents one evaluation: batched timing and usage are counted once, not once per answer. Indexes start at zero. Completion is not a guarantee of semantic correctness.
### Batched creation
With no `initialSpec`, `strategy: "batch"` is the default. The first evaluation selects the root and required components together. Shared `resource` variants use one exclusive choice; reusable recipes use bounded counts that include the root when applicable. Root selection takes precedence over speculative membership for the same recipe/resource. The first snapshot includes all selected elements in catalog order under the root's default slot, or its first declared slot when there is no default.
A second evaluation selects parents and sibling positions from the actual selected set. Equal positions retain catalog order. The combined tree must pass catalog, slot, depth, and tree validation before publication; a cycle or invalid layout throws and preserves the first snapshot as partial output. A single root or one child in a single slot needs no layout call. No separate finish call is made.
`maxElements` limits batched creation, including the root. A truncated selection, insufficient depth for selected content, or a call budget that prevents required layout returns `limit`. Use `strategy: "sequential"` for one-operation-at-a-time creation. Edits always use the sequential protocol.
Invalid configuration, out-of-set decisions, provider failures, and aborts throw. Previous snapshots remain usable as partial UI. Stopping iteration prevents further evaluation calls. A supplied signal also stops waiting for an evaluator that ignores cancellation; that evaluator must honor the signal to stop its underlying work.
### Custom evaluators
The composer is provider independent. An evaluator receives `state`, `questions`, and `signal`, then returns a selected criteria key for each question:
```typescript
const evaluate: Experimental_CompositionEvaluator = async ({ state, questions, signal }) => {
// Your adapter calls a decision model with this request.
const result = await yourEvaluator({ state, questions, signal });
return {
answers: result.answers, // { [questionName]: { choice: "offered_key", confidence: 0.9 } }
usage: { inputTokens: result.inputTokens }, // Optional
};
};
```
Questions are records of `type: "choice"`, `instructions`, and `criteria` (choice key to description). Return an answer for every question and treat question/choice keys as opaque. Batched creation sends `root` and `select_*` questions, then `parent_*` and `order_*` questions. Sequential creation and edits use `next` to select an operation, `finish`, or `unavailable`, with `parent` when several attachment points exist. Existing adapters that only implement `next`/`parent` must opt into `strategy: "sequential"`. Confidence must be in [0, 1] when provided; input tokens must be a nonnegative integer.
State contains `user_request` and explicit app `context`, plus capabilities/guidance during batched selection, `selected_elements` during layout, or `already_built` during sequential composition. Candidate descriptions and instructions are shared; raw state/props/binding values are not automatically included. See [validation and v1 limits](/docs/jev#validation-and-v1-limits).
### Follow-up edits
Pass `initialSpec` to edit an existing version. The composer validates and clones it, preserving unchanged elements, IDs, action bindings, and state. `initialState` explicitly overrides `initialSpec.state` when provided. Optional `elementDescriptions` maps existing IDs to descriptions shared with the evaluator; matching candidates supply the default description, otherwise only the component type is shared.
Editing adds bounded operations: replace an element with an offered recipe, remove a non-root subtree, and move/reorder a subtree to an allowed slot/position. Replacements preserve children and are offered only when the new component can contain them. Moves cannot create cycles or exceed the depth limit. Seed specs with cycles, shared children, missing references, unreachable nodes, or unsupported expressions are rejected before evaluation.
The `next` question offers opaque operation keys as well as candidate IDs. For replacement or movement, the following evaluation selects a recipe or destination, also through `next`. Both calls count toward `maxSteps`; the first emits an unchanged snapshot with its decision trace. `changes_made` supplies the edit trace alongside `already_built`. A budget limit, unavailable result, or cancellation may leave a selected edit unapplied. Existing elements exactly matching a recipe count toward `maxUses` and `resource`; removing/replacing them releases those limits.
## experimental_createEvaluator
**Experimental:** creates a server-side evaluator using Vercel AI Gateway's experimental v4 evaluation transport. No AI SDK dependency or provider constructor is required.
```typescript
import { experimental_createEvaluator } from "@json-render/core";
const evaluate = experimental_createEvaluator({
apiKey: process.env.AI_GATEWAY_API_KEY!, // Required; keep server-side
model: "typesafe-ai/jev", // Required, plain Gateway evaluation model ID
timeoutMs: 10_000, // Default, per evaluation
fetch: globalThis.fetch, // Optional transport override
});
```
The factory uses model-neutral naming and requires an explicit model. Jev is the current tested example; other models must support the Gateway choice-evaluation protocol. When using Jev, the Gateway team must permit TypeSafe AI. The adapter reports HTTP status on failure and rejects malformed/unoffered decisions. For Jev, it normalizes TypeSafe's native confidence rather than treating option probability as confidence. Confidence from other provider metadata is not yet normalized. Missing confidence and usage remain unknown. It does not retry automatically or estimate cost.
## defineCatalog
Creates a type-safe catalog definition with schema validation.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
function defineCatalog<T extends ZodType>(
s: T,
config: CatalogConfig
): Catalog
// Use the React schema for standard UI specs
const catalog = defineCatalog(schema, {
components: {...},
actions: {...},
});
```
### CatalogConfig
```typescript
interface CatalogConfig {
components: Record<string, ComponentDefinition>;
actions?: Record<string, ActionDefinition>;
functions?: Record<string, FunctionDefinition>;
}
interface ComponentDefinition {
props: ZodObject; // Use .nullable() for optional props
slots?: string[]; // Named slots (e.g., ["default"])
description?: string; // Help AI understand usage
}
interface ActionDefinition {
params?: ZodObject;
description?: string;
}
interface FunctionDefinition {
description?: string;
}
```
### Catalog Instance
The returned catalog provides methods for AI prompt generation, validation, and schema export:
```typescript
interface Catalog {
// Data
readonly data: CatalogConfig; // The catalog configuration
readonly componentNames: string[]; // List of component names
readonly actionNames: string[]; // List of action names
// AI Prompt Generation
prompt(options?: PromptOptions): string;
// Validation
validate(spec: unknown): SpecValidationResult;
zodSchema(): z.ZodType; // Get the Zod schema for specs
// Export
jsonSchema(): object; // Export as JSON Schema
}
interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"])
}
interface SpecValidationResult<T> {
success: boolean;
data?: T; // Validated spec (if success)
error?: z.ZodError; // Validation errors (if failed)
}
```
### Catalog Methods
```typescript
// Generate AI system prompt
const systemPrompt = catalog.prompt({
customRules: ["Always use Card as root element"],
});
// Validate a spec from AI
const result = catalog.validate(aiOutput);
if (result.success) {
render(result.data);
} else {
console.error(result.error);
}
// Get Zod schema for custom validation
const schema = catalog.zodSchema();
const parsed = schema.safeParse(aiOutput);
// Export as JSON Schema (for structured outputs)
const jsonSchema = catalog.jsonSchema();
```
## Schema System
json-render uses a flexible schema system that defines both the AI output format (spec) and what catalogs must provide. Each renderer package provides its own schema (e.g., @json-render/react exports `schema`).
### schema
The schema for flat UI element trees. This is exported from @json-render/react.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema } from '@json-render/react/schema';
// schema defines:
// - Spec shape: { root: string, elements: Record<string, UIElement> }
// - Catalog shape: { components: {...}, actions: {...} }
const catalog = defineCatalog(schema, {
components: {
Card: {
props: z.object({ title: z.string() }),
slots: ["default"],
description: "Container card",
},
},
actions: {
submit: {
params: z.object({ formId: z.string() }),
description: "Submit a form",
},
},
});
```
### SchemaOptions
When creating schemas with `defineSchema`, you can pass options:
```typescript
interface SchemaOptions {
promptTemplate?: PromptTemplate; // Custom AI prompt generator
defaultRules?: string[]; // Default rules injected before custom rules in prompts
builtInActions?: BuiltInAction[]; // Actions always available at runtime, auto-injected into prompts
}
interface BuiltInAction {
name: string; // Action name (e.g. "setState")
description: string; // Human-readable description for the LLM
}
```
Built-in actions are injected into prompts as `[built-in]` and are handled by the runtime (e.g. `ActionProvider`) without requiring handlers in `defineRegistry`. The React schema declares `setState`, `pushState`, and `removeState` as built-in.
### defineSchema
Create custom schemas for different output formats (e.g., page-based, block-based).
```typescript
import { defineSchema } from '@json-render/core';
const mySchema = defineSchema((s) => ({
// What the AI outputs (spec)
spec: s.object({
title: s.string(),
blocks: s.array(s.object({
type: s.ref("catalog.blocks"),
content: s.any(),
})),
}),
// What the catalog must provide
catalog: s.object({
blocks: s.map({
props: s.zod(),
description: s.string(),
}),
}),
}));
```
### Schema Builder API
The schema builder provides these methods:
```typescript
// Primitive types
s.string() // String value
s.number() // Number value
s.boolean() // Boolean value
s.any() // Any value
// Compound types
s.array(item) // Array of items
s.object({ ... }) // Object with shape
s.record(value) // Record/map with value type
// Catalog references (for type safety)
s.ref("catalog.components") // Reference to catalog key (becomes enum)
s.propsOf("catalog.components") // Props schema from catalog entry
// Catalog definitions
s.map({ props: s.zod(), ... }) // Map of named entries with shared shape
s.zod() // Placeholder for user-provided Zod schema
// Modifiers
s.optional() // Mark field as optional
```
## Zod Schemas
Pre-built Zod schemas for common json-render types:
### Dynamic Value Schemas
```typescript
import {
DynamicValueSchema, // string | number | boolean | null | { $state: string }
DynamicStringSchema, // string | { $state: string }
DynamicNumberSchema, // number | { $state: string }
DynamicBooleanSchema, // boolean | { $state: string }
} from '@json-render/core';
// Dynamic values can be literals or state path references
type DynamicValue<T> = T | { $state: string };
// Example: a prop that can be a literal or bound to state
const schema = z.object({
label: DynamicStringSchema, // "Hello" or { $state: "/user/name" }
});
```
### Visibility Schemas
```typescript
import { VisibilityConditionSchema } from '@json-render/core';
// Use in component props that need conditional rendering
const schema = z.object({
visible: VisibilityConditionSchema.optional(),
});
```
### Action Schemas
```typescript
import {
ActionSchema, // Full action definition
ActionConfirmSchema, // Confirmation dialog config
ActionOnSuccessSchema, // Success handler config
ActionOnErrorSchema, // Error handler config
} from '@json-render/core';
```
### Validation Schemas
```typescript
import {
ValidationCheckSchema, // Single validation check
ValidationConfigSchema, // Full validation config with checks array
} from '@json-render/core';
```
## SpecStream
SpecStream is json-render's streaming format for progressively building specs from JSONL patches.
### createSpecStreamCompiler
Create a streaming compiler that incrementally builds a spec:
```typescript
import { createSpecStreamCompiler } from '@json-render/core';
const compiler = createSpecStreamCompiler<MySpec>();
// Process streaming chunks
const { result, newPatches } = compiler.push(chunk);
// Get final result
const spec = compiler.getResult();
// Reset for reuse
compiler.reset();
```
### compileSpecStream
Compile an entire SpecStream string at once:
```typescript
import { compileSpecStream } from '@json-render/core';
const jsonl = `{"op":"add","path":"/root","value":{}}
{"op":"add","path":"/root/type","value":"Card"}`;
const spec = compileSpecStream<MySpec>(jsonl);
```
### Low-Level Utilities
```typescript
import {
parseSpecStreamLine,
applySpecStreamPatch,
} from '@json-render/core';
// Parse a single line
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);
```
### applySpecPatch
Apply a single SpecStream patch to a Spec object (mutates in place, returns the spec):
```typescript
import { applySpecPatch } from '@json-render/core';
let spec: Spec = { root: "", elements: {} };
applySpecPatch(spec, { op: "add", path: "/root", value: "main" });
// For React state updates, spread to create a new reference:
setSpec({ ...applySpecPatch(spec, patch) });
```
### nestedToFlat
Convert a nested element tree (with inline children and named slots) into the flat `Spec` format:
```typescript
import { nestedToFlat } from '@json-render/core';
const flat = nestedToFlat({
type: "Layout",
props: {},
children: [{ type: "Text", props: { content: "Main" }, children: [] }],
slots: {
header: [{ type: "Heading", props: { text: "Header" }, children: [] }],
},
});
// { root: "el-0", elements: { "el-0": ..., "el-1": ... } }
```
### createJsonRenderTransform
Low-level `TransformStream` that separates text from JSONL patches in a mixed AI stream. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text.
The transform properly splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
```typescript
import { createJsonRenderTransform } from '@json-render/core';
const transform = createJsonRenderTransform();
// Use with ReadableStream.pipeThrough(transform) for custom pipelines
```
Most users should use `pipeJsonRender()` instead, which wraps this transform for the common AI SDK use case.
### createMixedStreamParser
Parse a mixed stream of text and JSONL patches (used for Inline mode):
```typescript
import { createMixedStreamParser } from '@json-render/core';
const parser = createMixedStreamParser({
onText: (text) => appendToMessage(text),
onPatch: (patch) => applySpecPatch(spec, patch),
});
// As chunks arrive from the stream:
for await (const chunk of stream) {
parser.push(chunk);
}
parser.flush();
```
### pipeJsonRender
Pipe an AI SDK `UIMessageStream` through the json-render transform. Lines that parse as JSONL patches are emitted as `data-spec` parts; everything else passes through as text. Used in Inline mode API routes.
```typescript
import { pipeJsonRender } from '@json-render/core';
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai';
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeJsonRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
See [Generation Modes](/docs/generation-modes) for full Inline mode setup.
### SpecStream Types
Fully compliant with [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902):
```typescript
interface SpecStreamLine {
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
path: string;
value?: unknown; // Required for add, replace, test
from?: string; // Required for move, copy
}
interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T;
getPatches(): SpecStreamLine[];
reset(): void;
}
interface MixedStreamCallbacks {
onText: (text: string) => void;
onPatch: (patch: SpecStreamLine) => void;
}
interface MixedStreamParser {
push(chunk: string): void;
flush(): void;
}
```
## Utility Functions
### Path Utilities
```typescript
import { getByPath, setByPath } from '@json-render/core';
// Get value by JSON Pointer path
const value = getByPath(state, '/user/name'); // "Alice"
// Set value by path (mutates object)
setByPath(state, '/user/email', 'alice@example.com');
```
### resolveDynamicValue
```typescript
import { resolveDynamicValue } from '@json-render/core';
// Resolve a dynamic value against state
const name = resolveDynamicValue("Hello", state); // "Hello"
const name2 = resolveDynamicValue({ $state: "/user/name" }, state); // "Alice"
```
### findFormValue
Read a value from resolved action parameters or state. A parameter value is literal, including strings with dots such as emails, URLs, and versions. Lookup order: a defined direct parameter, a parameter key ending in `.<fieldName>`, a matching flat state key, then a slash-delimited path in nested state.
```typescript
import { findFormValue } from '@json-render/core';
findFormValue("email", { email: "john.doe@example.com" }, {});
findFormValue("email", { "form.email": "john.doe@example.com" }, {});
findFormValue("email", {}, { "form.email": "john.doe@example.com" });
findFormValue("/form/email", {}, { form: { email: "john.doe@example.com" } });
```
For action bindings, use `{ $state: "/form/email" }` to read nested state: the action resolver passes the resulting value to the handler. A raw string like `"form.email"` in parameters is not a state reference. A bare `"email"` field name does not search `state.form.email`.
## buildUserPrompt
Build structured user prompts for AI generation, with support for refinement and state context.
```typescript
import { buildUserPrompt } from '@json-render/core';
function buildUserPrompt(options: UserPromptOptions): string
interface UserPromptOptions {
prompt: string; // The user's text prompt
currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode)
state?: Record<string, unknown> | null; // Runtime state context to include
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"])
}
```
### Fresh generation
```typescript
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
```
### Refinement (edit modes)
When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff).
```typescript
const userPrompt = buildUserPrompt({
prompt: "add a dark mode toggle",
currentSpec: existingSpec,
editModes: ["patch", "merge"],
});
```
### With state context
Include runtime state so the AI knows what data is available:
```typescript
const userPrompt = buildUserPrompt({
prompt: "show my data",
state: { todos: [{ text: "Buy milk" }] },
});
```
## Edit Modes
Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use.
```typescript
import {
buildEditInstructions,
buildEditUserPrompt,
isNonEmptySpec,
type EditMode,
type EditConfig,
} from '@json-render/core';
type EditMode = "patch" | "merge" | "diff";
```
### buildEditInstructions
Generate the prompt section describing available edit modes. Supports both JSON and YAML formats.
```typescript
function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string
const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json");
```
### buildEditUserPrompt
Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions.
```typescript
function buildEditUserPrompt(options: BuildEditUserPromptOptions): string
interface BuildEditUserPromptOptions {
prompt: string;
currentSpec?: Spec | null;
config?: EditConfig;
format: "json" | "yaml";
maxPromptLength?: number;
serializer?: (spec: Spec) => string;
}
```
### isNonEmptySpec
Check whether a value is a non-empty spec (has a root string and at least one element).
```typescript
function isNonEmptySpec(spec: unknown): spec is Spec
```
## Deep Merge and Diff
Format-agnostic utilities for merging and diffing spec objects.
### deepMergeSpec
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated.
```typescript
import { deepMergeSpec } from '@json-render/core';
function deepMergeSpec(
base: Record<string, unknown>,
patch: Record<string, unknown>
): Record<string, unknown>
const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } });
```
### diffToPatches
Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse.
```typescript
import { diffToPatches } from '@json-render/core';
function diffToPatches(
oldObj: Record<string, unknown>,
newObj: Record<string, unknown>,
basePath?: string
): JsonPatch[]
const patches = diffToPatches(oldSpec, newSpec);
// [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }]
```
## evaluateVisibility
Evaluates a visibility condition against the state model.
```typescript
function evaluateVisibility(
condition: VisibilityCondition | undefined,
ctx: VisibilityContext
): boolean
interface VisibilityContext {
stateModel: StateModel;
repeatItem?: unknown; // Current repeat item (inside repeat scope)
repeatIndex?: number; // Current repeat array index (inside repeat scope)
}
type VisibilityCondition =
| { $state: string } // truthiness
| { $state: string; not: true } // falsy
| { $state: string; eq: unknown } // equality
| { $state: string; neq: unknown } // inequality
| { $state: string; gt: number } // greater than
| { $state: string; gte: number } // gte
| { $state: string; lt: number } // lt
| { $state: string; lte: number } // lte
| { $item: string } // item field (repeat scope)
| { $item: string; eq: unknown } // item field equality
| { $index: true } // index truthiness (repeat scope)
| { $index: true; gt: number } // index comparison
| VisibilityCondition[] // implicit AND
| { $and: VisibilityCondition[] } // explicit AND
| { $or: VisibilityCondition[] } // OR
| boolean; // always / never
```
## Types
### UIElement
```typescript
interface UIElement {
type: string;
props: Record<string, unknown>;
children?: string[]; // Keys of child elements
slots?: Record<string, string[]>; // Named slots mapped to child keys
visible?: VisibilityCondition;
on?: Record<string, ActionBinding | ActionBinding[]>; // Event bindings
repeat?: { statePath: string | { $item: string }; key?: string }; // Repeat for arrays
}
```
Elements are stored in the `elements` map keyed by string IDs. The key comes from the map, not from the element itself.
### Spec (Element Tree)
```typescript
interface Spec {
root: string | null; // Key of root element
elements: Record<string, UIElement>; // Flat element map
state?: Record<string, unknown>; // Initial state model
}
```
Elements are stored as a flat map with string keys. The tree structure is built by following `children` and named `slots` references.
### ActionBinding
```typescript
interface ActionBinding {
action: string;
params?: Record<string, DynamicValue>;
confirm?: {
title: string;
message: string;
variant?: 'default' | 'danger';
};
onSuccess?: { set: Record<string, unknown> };
onError?: { set: Record<string, unknown> };
preventDefault?: boolean; // Prevent default browser behavior (e.g. navigation on links)
}
```
### ValidationSchema
```typescript
interface ValidationSchema {
checks: ValidationCheck[];
validateOn?: 'change' | 'blur' | 'submit';
}
interface ValidationCheck {
type: string;
args?: Record<string, unknown>;
message: string;
}
```
@@ -1,58 +0,0 @@
---
title: "@json-render/devtools-react"
---
React adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
## JsonRenderDevtools
```tsx
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>
```
### Props
```tsx
interface JsonRenderDevtoolsProps {
/** Current spec being rendered. */
spec?: Spec | null;
/** Catalog definition (required for the Catalog panel). */
catalog?: Catalog | null;
/** AI SDK useChat messages array. */
messages?: readonly UIMessage[];
/** Start the panel open. Default: false. */
initialOpen?: boolean;
/** Floating toggle position. */
position?: "bottom-right" | "bottom-left" | "right";
/** Toggle keybinding, or false to disable. Default: "mod+shift+j". */
hotkey?: string | false;
/** Ring buffer size. Default: 500. */
bufferSize?: number;
/** Fires for every devtools event. */
onEvent?: (evt: DevtoolsEvent) => void;
}
```
In production builds the component renders `null`.
## useJsonRenderDevtools
```tsx
import { useJsonRenderDevtools } from "@json-render/devtools-react";
const devtools = useJsonRenderDevtools();
devtools?.open();
devtools?.toggle();
devtools?.close();
devtools?.clear();
devtools?.recordEvent({ kind: "stream-text", at: Date.now(), text: "hi" });
```
Access the running devtools instance from anywhere in the React tree. Returns `null` in production or before the component has mounted.
@@ -1,37 +0,0 @@
---
title: "@json-render/devtools-solid"
---
SolidJS adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
## JsonRenderDevtools
```tsx
import { JsonRenderDevtools } from "@json-render/devtools-solid";
<JSONUIProvider registry={registry}>
<Renderer spec={spec()} registry={registry} />
<JsonRenderDevtools
spec={spec()}
catalog={catalog}
messages={messages()}
/>
</JSONUIProvider>
```
### Props
Same shape as the React adapter:
- `spec`
- `catalog`
- `messages`
- `initialOpen`
- `position`
- `hotkey`
- `bufferSize`
- `onEvent`
In production builds the component renders `null`.
@@ -1,35 +0,0 @@
---
title: "@json-render/devtools-svelte"
---
Svelte adapter for the json-render devtools. Drop-in `<JsonRenderDevtools />` component.
See the [Devtools guide](/docs/devtools) for the drop-in walkthrough.
## JsonRenderDevtools
```svelte
<script>
import { JsonRenderDevtools } from "@json-render/devtools-svelte";
</script>
<JSONUIProvider {registry}>
<Renderer {spec} {registry} />
<JsonRenderDevtools {spec} {catalog} {messages} />
</JSONUIProvider>
```
### Props
Same shape as the React adapter:
- `spec`
- `catalog`
- `messages`
- `initialOpen`
- `position`
- `hotkey`
- `bufferSize`
- `onEvent`
In production builds the component renders nothing.

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