mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22545b49cc | ||
|
|
a8afd8bfe2 | ||
|
|
dc489601be | ||
|
|
7758d4bbd5 | ||
|
|
6225fc41eb | ||
|
|
5b32de8720 | ||
|
|
f6d1c5134b | ||
|
|
316439fcd7 | ||
|
|
c180f529a0 | ||
|
|
c3c0a36f20 | ||
|
|
9bb82604f0 | ||
|
|
caa90b8b84 | ||
|
|
54a1ecf817 | ||
|
|
dc9e9d17f3 | ||
|
|
1977fb3a11 | ||
|
|
4606c01085 | ||
|
|
c1a700d719 | ||
|
|
6f15faaae0 | ||
|
|
63c339b4bb | ||
|
|
1cc87310c9 | ||
|
|
5b929dffa6 | ||
|
|
512f7fe5c5 | ||
|
|
b6f12d4d53 | ||
|
|
f29b1c2ef6 | ||
|
|
8968bd648a | ||
|
|
023ca789b2 | ||
|
|
3f1e71e779 | ||
|
|
553c803422 | ||
|
|
9f58d8712c | ||
|
|
c2b397510e | ||
|
|
8506cfaa03 | ||
|
|
9cef4e9142 | ||
|
|
3c11f19be4 | ||
|
|
db3a8b41e9 | ||
|
|
ea47b66dfc | ||
|
|
cd82f969c8 | ||
|
|
6bcaaad57d | ||
|
|
0b7d767cdd | ||
|
|
b1036763d2 | ||
|
|
c502d5517e | ||
|
|
8740deb018 | ||
|
|
1d755c104a | ||
|
|
d904d45150 | ||
|
|
a110c6e0ea | ||
|
|
7de08ccdf7 | ||
|
|
3201854481 | ||
|
|
64c889221e | ||
|
|
49838fa353 | ||
|
|
fa47b08869 | ||
|
|
5ccb109c08 | ||
|
|
62932f6516 | ||
|
|
ee28d548c1 | ||
|
|
0e6f2afc6c | ||
|
|
bccedc2459 |
+14
-2
@@ -6,11 +6,23 @@
|
|||||||
[
|
[
|
||||||
"@json-render/core",
|
"@json-render/core",
|
||||||
"@json-render/react",
|
"@json-render/react",
|
||||||
|
"@json-render/react-email",
|
||||||
"@json-render/react-pdf",
|
"@json-render/react-pdf",
|
||||||
"@json-render/shadcn",
|
"@json-render/shadcn",
|
||||||
"@json-render/react-native",
|
"@json-render/react-native",
|
||||||
"@json-render/remotion",
|
"@json-render/remotion",
|
||||||
"@json-render/codegen"
|
"@json-render/codegen",
|
||||||
|
"@json-render/zustand",
|
||||||
|
"@json-render/redux",
|
||||||
|
"@json-render/jotai",
|
||||||
|
"@json-render/vue",
|
||||||
|
"@json-render/xstate",
|
||||||
|
"@json-render/image",
|
||||||
|
"@json-render/mcp",
|
||||||
|
"@json-render/svelte",
|
||||||
|
"@json-render/solid",
|
||||||
|
"@json-render/react-three-fiber",
|
||||||
|
"@json-render/yaml"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"linked": [],
|
"linked": [],
|
||||||
@@ -18,7 +30,7 @@
|
|||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"privatePackages": {
|
"privatePackages": {
|
||||||
"version": false,
|
"version": true,
|
||||||
"tag": false
|
"tag": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"json-render": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-25
@@ -13,34 +13,43 @@ concurrency:
|
|||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
lint:
|
||||||
name: Lint, Type Check & Build
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: "pnpm"
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm lint
|
||||||
|
|
||||||
- name: Install dependencies
|
test:
|
||||||
run: pnpm install --frozen-lockfile
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Build packages
|
||||||
|
run: pnpm turbo run build --filter='./packages/*'
|
||||||
|
- run: pnpm test
|
||||||
|
|
||||||
- name: Lint
|
typecheck:
|
||||||
run: pnpm lint
|
name: Type Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
- name: Type check
|
steps:
|
||||||
run: pnpm type-check
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
- name: Test
|
- uses: actions/setup-node@v4
|
||||||
run: pnpm test
|
with:
|
||||||
|
node-version: 20
|
||||||
- name: Build
|
cache: pnpm
|
||||||
run: pnpm build
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- run: pnpm type-check
|
||||||
|
|||||||
+4
-9
@@ -4,13 +4,11 @@
|
|||||||
node_modules
|
node_modules
|
||||||
.pnp
|
.pnp
|
||||||
.pnp.js
|
.pnp.js
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env*
|
||||||
.env.local
|
!.env.example
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
@@ -30,6 +28,7 @@ out/
|
|||||||
build
|
build
|
||||||
dist
|
dist
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
.svelte-kit/
|
||||||
|
|
||||||
|
|
||||||
# Debug
|
# Debug
|
||||||
@@ -44,10 +43,6 @@ yarn-error.log*
|
|||||||
# opensrc - source code for packages
|
# opensrc - source code for packages
|
||||||
opensrc/
|
opensrc/
|
||||||
|
|
||||||
# json-studio (separate repo)
|
|
||||||
json-studio/
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# Stripe apps (generated from template + build artifacts)
|
# Stripe apps (generated from template + build artifacts)
|
||||||
examples/stripe-app/*/stripe-app.json
|
examples/stripe-app/*/stripe-app.json
|
||||||
examples/stripe-app/*/.build
|
examples/stripe-app/*/.build
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"json-render": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["tsx", "examples/mcp/server.ts", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,42 @@ This ensures we don't install outdated versions that may have incompatible types
|
|||||||
|
|
||||||
- Do not use emojis in code or UI
|
- Do not use emojis in code or UI
|
||||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
- 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
|
## Workflow
|
||||||
|
|
||||||
@@ -33,9 +69,45 @@ This ensures we don't install outdated versions that may have incompatible types
|
|||||||
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
- 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`
|
- Package `README.md` files in `packages/*/README.md`
|
||||||
- Root `README.md` (if packages table, install commands, or examples are affected)
|
- 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)
|
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||||
- `AGENTS.md` (if workflow or conventions change)
|
- `AGENTS.md` (if workflow or conventions change)
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
|
||||||
|
|
||||||
|
### Fixed version group
|
||||||
|
|
||||||
|
All public `@json-render/*` packages are in a **fixed** group (see `.changeset/config.json`). A changeset that bumps any one of them bumps all of them to the same version. You only need to list the packages that actually changed in the changeset front matter — the fixed group handles the rest.
|
||||||
|
|
||||||
|
### Preparing a release
|
||||||
|
|
||||||
|
When asked to prepare a release (e.g. "prepare v0.12.0"):
|
||||||
|
|
||||||
|
1. **Create a changeset file** at `.changeset/v0-<N>-release.md` following the existing pattern:
|
||||||
|
- YAML front matter listing changed packages with bump type (`minor` for feature releases, `patch` for bug-fix-only releases)
|
||||||
|
- A one-line summary, then `### New:` / `### Improved:` / `### Fixed:` sections describing each change
|
||||||
|
- Always list `@json-render/core` plus any packages with actual code changes
|
||||||
|
2. **Do NOT bump versions** in `package.json` files — CI runs `pnpm ci:version` (which calls `changeset version`) to do that automatically
|
||||||
|
3. **Do NOT manually write `CHANGELOG.md`** entries — `changeset version` generates them from the changeset file
|
||||||
|
4. **Add new packages to the fixed group** in `.changeset/config.json` if they should be versioned together with the rest
|
||||||
|
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/app/(main)/docs/api/<name>/page.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
|
||||||
|
|
||||||
|
### CI scripts
|
||||||
|
|
||||||
|
- `pnpm changeset` — interactively create a new changeset
|
||||||
|
- `pnpm ci:version` — run `changeset version` + lockfile update (CI only)
|
||||||
|
- `pnpm ci:publish` — build all packages and publish to npm (CI only)
|
||||||
|
|
||||||
<!-- opensrc:start -->
|
<!-- opensrc:start -->
|
||||||
|
|
||||||
## Source Code Reference
|
## Source Code Reference
|
||||||
|
|||||||
@@ -5,15 +5,26 @@
|
|||||||
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
Generate dynamic, personalized UIs from prompts without sacrificing reliability. Predefined components and actions for safe, predictable output.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# for React
|
||||||
npm install @json-render/core @json-render/react
|
npm install @json-render/core @json-render/react
|
||||||
# pre-built shadcn/ui components
|
# for React with pre-built shadcn/ui components
|
||||||
npm install @json-render/shadcn
|
npm install @json-render/shadcn
|
||||||
# or for mobile
|
# or for React Native
|
||||||
npm install @json-render/core @json-render/react-native
|
npm install @json-render/core @json-render/react-native
|
||||||
# or for video
|
# or for video
|
||||||
npm install @json-render/core @json-render/remotion
|
npm install @json-render/core @json-render/remotion
|
||||||
# or for PDF documents
|
# or for PDF documents
|
||||||
npm install @json-render/core @json-render/react-pdf
|
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 3D scenes
|
||||||
|
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
|
||||||
```
|
```
|
||||||
|
|
||||||
## Why json-render?
|
## Why json-render?
|
||||||
@@ -23,7 +34,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
|||||||
- **Guardrailed** - AI can only use components in your catalog
|
- **Guardrailed** - AI can only use components in your catalog
|
||||||
- **Predictable** - JSON output matches your schema, every time
|
- **Predictable** - JSON output matches your schema, every time
|
||||||
- **Fast** - Stream and render progressively as the model responds
|
- **Fast** - Stream and render progressively as the model responds
|
||||||
- **Cross-Platform** - React (web) and React Native (mobile) from the same catalog
|
- **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
|
- **Batteries Included** - 36 pre-built shadcn/ui components ready to use
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
@@ -32,7 +43,7 @@ json-render is a **Generative UI** framework: AI generates interfaces from natur
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from "@json-render/core";
|
import { defineCatalog } from "@json-render/core";
|
||||||
import { schema } from "@json-render/react";
|
import { schema } from "@json-render/react/schema";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
const catalog = defineCatalog(schema, {
|
||||||
@@ -84,9 +95,7 @@ const { registry } = defineRegistry(catalog, {
|
|||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
Button: ({ props, emit }) => (
|
Button: ({ props, emit }) => (
|
||||||
<button onClick={() => emit("press")}>
|
<button onClick={() => emit("press")}>{props.label}</button>
|
||||||
{props.label}
|
|
||||||
</button>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -106,14 +115,27 @@ function Dashboard({ spec }) {
|
|||||||
|
|
||||||
## Packages
|
## Packages
|
||||||
|
|
||||||
| Package | Description |
|
| Package | Description |
|
||||||
|---------|-------------|
|
| --------------------------- | ---------------------------------------------------------------------- |
|
||||||
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
| `@json-render/core` | Schemas, catalogs, AI prompts, dynamic props, SpecStream utilities |
|
||||||
| `@json-render/react` | React renderer, contexts, hooks |
|
| `@json-render/react` | React renderer, contexts, hooks |
|
||||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
| `@json-render/vue` | Vue 3 renderer, composables, providers |
|
||||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
| `@json-render/svelte` | Svelte 5 renderer with runes-based reactivity |
|
||||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
| `@json-render/solid` | SolidJS renderer with fine-grained reactive contexts |
|
||||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||||
|
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
|
||||||
|
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||||
|
| `@json-render/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/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
|
||||||
|
| `@json-render/codegen` | Utilities for generating code from json-render UI trees |
|
||||||
|
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||||
|
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||||
|
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||||
|
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
|
||||||
|
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
|
||||||
|
| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform |
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
@@ -121,7 +143,7 @@ function Dashboard({ spec }) {
|
|||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { defineRegistry, Renderer } from "@json-render/react";
|
import { defineRegistry, Renderer } from "@json-render/react";
|
||||||
import { schema } from "@json-render/react";
|
import { schema } from "@json-render/react/schema";
|
||||||
|
|
||||||
// Flat spec format (root key + elements map)
|
// Flat spec format (root key + elements map)
|
||||||
const spec = {
|
const spec = {
|
||||||
@@ -142,14 +164,72 @@ const spec = {
|
|||||||
|
|
||||||
// defineRegistry creates a type-safe component registry
|
// defineRegistry creates a type-safe component registry
|
||||||
const { registry } = defineRegistry(catalog, { components });
|
const { registry } = defineRegistry(catalog, { components });
|
||||||
<Renderer spec={spec} registry={registry} />
|
<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)
|
### shadcn/ui (Web)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { defineCatalog } from "@json-render/core";
|
import { defineCatalog } from "@json-render/core";
|
||||||
import { schema, defineRegistry, Renderer } from "@json-render/react";
|
import { schema } from "@json-render/react/schema";
|
||||||
|
import { defineRegistry, Renderer } from "@json-render/react";
|
||||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||||
import { shadcnComponents } from "@json-render/shadcn";
|
import { shadcnComponents } from "@json-render/shadcn";
|
||||||
|
|
||||||
@@ -174,7 +254,7 @@ const { registry } = defineRegistry(catalog, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
<Renderer spec={spec} registry={registry} />
|
<Renderer spec={spec} registry={registry} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
### React Native (Mobile)
|
### React Native (Mobile)
|
||||||
@@ -195,23 +275,40 @@ const catalog = defineCatalog(schema, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { registry } = defineRegistry(catalog, { components: {} });
|
const { registry } = defineRegistry(catalog, { components: {} });
|
||||||
<Renderer spec={spec} registry={registry} />
|
<Renderer spec={spec} registry={registry} />;
|
||||||
```
|
```
|
||||||
|
|
||||||
### Remotion (Video)
|
### Remotion (Video)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
import { Player } from "@remotion/player";
|
import { Player } from "@remotion/player";
|
||||||
import { Renderer, schema, standardComponentDefinitions } from "@json-render/remotion";
|
import {
|
||||||
|
Renderer,
|
||||||
|
schema,
|
||||||
|
standardComponentDefinitions,
|
||||||
|
} from "@json-render/remotion";
|
||||||
|
|
||||||
// Timeline spec format
|
// Timeline spec format
|
||||||
const spec = {
|
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 }],
|
tracks: [{ id: "main", name: "Main", type: "video", enabled: true }],
|
||||||
clips: [
|
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
|
<Player
|
||||||
@@ -221,7 +318,7 @@ const spec = {
|
|||||||
fps={spec.composition.fps}
|
fps={spec.composition.fps}
|
||||||
compositionWidth={spec.composition.width}
|
compositionWidth={spec.composition.width}
|
||||||
compositionHeight={spec.composition.height}
|
compositionHeight={spec.composition.height}
|
||||||
/>
|
/>;
|
||||||
```
|
```
|
||||||
|
|
||||||
### React PDF (Documents)
|
### React PDF (Documents)
|
||||||
@@ -232,7 +329,11 @@ import { renderToBuffer } from "@json-render/react-pdf";
|
|||||||
const spec = {
|
const spec = {
|
||||||
root: "doc",
|
root: "doc",
|
||||||
elements: {
|
elements: {
|
||||||
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
|
doc: {
|
||||||
|
type: "Document",
|
||||||
|
props: { title: "Invoice" },
|
||||||
|
children: ["page-1"],
|
||||||
|
},
|
||||||
"page-1": {
|
"page-1": {
|
||||||
type: "Page",
|
type: "Page",
|
||||||
props: { size: "A4" },
|
props: { size: "A4" },
|
||||||
@@ -246,8 +347,14 @@ const spec = {
|
|||||||
"table-1": {
|
"table-1": {
|
||||||
type: "Table",
|
type: "Table",
|
||||||
props: {
|
props: {
|
||||||
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
|
columns: [
|
||||||
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
|
{ header: "Item", width: "60%" },
|
||||||
|
{ header: "Price", width: "40%", align: "right" },
|
||||||
|
],
|
||||||
|
rows: [
|
||||||
|
["Widget A", "$10.00"],
|
||||||
|
["Widget B", "$25.00"],
|
||||||
|
],
|
||||||
},
|
},
|
||||||
children: [],
|
children: [],
|
||||||
},
|
},
|
||||||
@@ -258,6 +365,120 @@ const spec = {
|
|||||||
const buffer = await renderToBuffer(spec);
|
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,
|
||||||
|
OrbitControls: threeComponentDefinitions.OrbitControls,
|
||||||
|
},
|
||||||
|
actions: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Box: threeComponents.Box,
|
||||||
|
Sphere: threeComponents.Sphere,
|
||||||
|
AmbientLight: threeComponents.AmbientLight,
|
||||||
|
DirectionalLight: threeComponents.DirectionalLight,
|
||||||
|
OrbitControls: threeComponents.OrbitControls,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
<ThreeCanvas
|
||||||
|
spec={spec}
|
||||||
|
registry={registry}
|
||||||
|
shadows
|
||||||
|
camera={{ position: [5, 5, 5], fov: 50 }}
|
||||||
|
style={{ width: "100%", height: "100vh" }}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Streaming (SpecStream)
|
### Streaming (SpecStream)
|
||||||
@@ -307,16 +528,26 @@ Any prop value can be data-driven using expressions:
|
|||||||
{
|
{
|
||||||
"type": "Icon",
|
"type": "Icon",
|
||||||
"props": {
|
"props": {
|
||||||
"name": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "home", "$else": "home-outline" },
|
"name": {
|
||||||
"color": { "$cond": { "$state": "/activeTab", "eq": "home" }, "$then": "#007AFF", "$else": "#8E8E93" }
|
"$cond": { "$state": "/activeTab", "eq": "home" },
|
||||||
|
"$then": "home",
|
||||||
|
"$else": "home-outline"
|
||||||
|
},
|
||||||
|
"color": {
|
||||||
|
"$cond": { "$state": "/activeTab", "eq": "home" },
|
||||||
|
"$then": "#007AFF",
|
||||||
|
"$else": "#8E8E93"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Two expression forms:
|
Expression forms:
|
||||||
|
|
||||||
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
- **`{ "$state": "/state/key" }`** - reads a value from the state model
|
||||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - evaluates a condition (same syntax as visibility conditions) and picks a branch
|
- **`{ "$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
|
### Actions
|
||||||
|
|
||||||
@@ -325,13 +556,38 @@ Components can trigger actions, including the built-in `setState` action:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "Pressable",
|
"type": "Pressable",
|
||||||
"props": { "action": "setState", "actionParams": { "statePath": "/activeTab", "value": "home" } },
|
"props": {
|
||||||
|
"action": "setState",
|
||||||
|
"actionParams": { "statePath": "/activeTab", "value": "home" }
|
||||||
|
},
|
||||||
"children": ["home-icon"]
|
"children": ["home-icon"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The `setState` action updates the state model directly, which re-evaluates visibility conditions and dynamic prop expressions.
|
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" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`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
|
## Demo
|
||||||
@@ -343,10 +599,14 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- http://localhost:3000 - Docs & Playground
|
- http://json-render.localhost:1355 - Docs & Playground
|
||||||
- http://localhost:3001 - Example Dashboard
|
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
|
||||||
- http://localhost:3002 - Remotion Video Example
|
- 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`
|
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||||
|
- 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`
|
- React Native example: run `npx expo start` in `examples/react-native`
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -356,7 +616,7 @@ flowchart LR
|
|||||||
A[User Prompt] --> B[AI + Catalog]
|
A[User Prompt] --> B[AI + Catalog]
|
||||||
B --> C[JSON Spec]
|
B --> C[JSON Spec]
|
||||||
C --> D[Renderer]
|
C --> D[Renderer]
|
||||||
|
|
||||||
B -.- E([guardrailed])
|
B -.- E([guardrailed])
|
||||||
C -.- F([predictable])
|
C -.- F([predictable])
|
||||||
D -.- G([streamed])
|
D -.- G([streamed])
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# web
|
||||||
|
|
||||||
|
## 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
-1
@@ -14,7 +14,7 @@ pnpm dev
|
|||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) 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.
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "A2UI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/a2ui")
|
||||||
|
|
||||||
# A2UI Integration
|
# A2UI Integration
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ A2UI uses an adjacency list model - a flat list of components with ID references
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// A2UI BoundValue schema
|
// A2UI BoundValue schema
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Adaptive Cards Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/adaptive-cards")
|
||||||
|
|
||||||
# Adaptive Cards Integration
|
# Adaptive Cards Integration
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ Define a catalog matching the Adaptive Cards element types:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Common Adaptive Cards properties
|
// Common Adaptive Cards properties
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "AG-UI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/ag-ui")
|
||||||
|
|
||||||
# AG-UI Integration
|
# AG-UI Integration
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ Create a catalog for UI components that agents can render:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const aguiCatalog = defineCatalog(schema, {
|
export const aguiCatalog = defineCatalog(schema, {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
export const metadata = { title: "AI SDK Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/ai-sdk")
|
||||||
|
|
||||||
# AI SDK Integration
|
# AI SDK Integration
|
||||||
|
|
||||||
Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless streaming. json-render supports two modes: **Generate** (standalone UI) and **Chat** (UI embedded in conversation). See [Generation Modes](/docs/generation-modes) for a detailed comparison.
|
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
|
## Installation
|
||||||
|
|
||||||
@@ -10,9 +11,9 @@ Use json-render with the [Vercel AI SDK](https://sdk.vercel.ai) for seamless str
|
|||||||
npm install ai @ai-sdk/react
|
npm install ai @ai-sdk/react
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generate Mode
|
## Standalone Mode
|
||||||
|
|
||||||
In generate 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.
|
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
|
### API Route
|
||||||
|
|
||||||
@@ -72,9 +73,9 @@ function GenerativeUI() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Chat Mode
|
## Inline Mode
|
||||||
|
|
||||||
In chat 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.
|
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
|
### API Route
|
||||||
|
|
||||||
@@ -95,7 +96,7 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: yourModel,
|
model: yourModel,
|
||||||
system: catalog.prompt({ mode: "chat" }),
|
system: catalog.prompt({ mode: "inline" }),
|
||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,13 +182,13 @@ const systemPrompt = catalog.prompt({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chat Mode Prompt
|
### Inline Mode Prompt
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
const inlinePrompt = catalog.prompt({ mode: "inline" });
|
||||||
```
|
```
|
||||||
|
|
||||||
In chat 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.
|
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?
|
## Which Mode?
|
||||||
|
|
||||||
@@ -196,8 +197,8 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th></th>
|
<th></th>
|
||||||
<th>Generate</th>
|
<th>Standalone</th>
|
||||||
<th>Chat</th>
|
<th>Inline</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -214,7 +215,7 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
|
|||||||
<tr>
|
<tr>
|
||||||
<td>System prompt</td>
|
<td>System prompt</td>
|
||||||
<td><code>catalog.prompt()</code></td>
|
<td><code>catalog.prompt()</code></td>
|
||||||
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
|
<td><code>{"catalog.prompt({ mode: \"inline\" })"}</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Stream utility</td>
|
<td>Stream utility</td>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/codegen API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/codegen")
|
||||||
|
|
||||||
# @json-render/codegen
|
# @json-render/codegen
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/core API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/core")
|
||||||
|
|
||||||
# @json-render/core
|
# @json-render/core
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ Creates a type-safe catalog definition with schema validation.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
|
|
||||||
function defineCatalog<T extends ZodType>(
|
function defineCatalog<T extends ZodType>(
|
||||||
s: T,
|
s: T,
|
||||||
@@ -74,7 +75,8 @@ interface Catalog {
|
|||||||
interface PromptOptions {
|
interface PromptOptions {
|
||||||
system?: string; // Custom system message intro
|
system?: string; // Custom system message intro
|
||||||
customRules?: string[]; // Additional rules to append
|
customRules?: string[]; // Additional rules to append
|
||||||
mode?: "generate" | "chat"; // Output mode (default: "generate")
|
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
|
||||||
|
editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"])
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpecValidationResult<T> {
|
interface SpecValidationResult<T> {
|
||||||
@@ -118,7 +120,7 @@ The schema for flat UI element trees. This is exported from @json-render/react.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
|
|
||||||
// schema defines:
|
// schema defines:
|
||||||
// - Spec shape: { root: string, elements: Record<string, UIElement> }
|
// - Spec shape: { root: string, elements: Record<string, UIElement> }
|
||||||
@@ -369,7 +371,7 @@ Most users should use `pipeJsonRender()` instead, which wraps this transform for
|
|||||||
|
|
||||||
### createMixedStreamParser
|
### createMixedStreamParser
|
||||||
|
|
||||||
Parse a mixed stream of text and JSONL patches (used for Chat + GenUI mode):
|
Parse a mixed stream of text and JSONL patches (used for Inline mode):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { createMixedStreamParser } from '@json-render/core';
|
import { createMixedStreamParser } from '@json-render/core';
|
||||||
@@ -388,7 +390,7 @@ parser.flush();
|
|||||||
|
|
||||||
### pipeJsonRender
|
### 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 Chat mode API routes.
|
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
|
```typescript
|
||||||
import { pipeJsonRender } from '@json-render/core';
|
import { pipeJsonRender } from '@json-render/core';
|
||||||
@@ -402,7 +404,7 @@ const stream = createUIMessageStream({
|
|||||||
return createUIMessageStreamResponse({ stream });
|
return createUIMessageStreamResponse({ stream });
|
||||||
```
|
```
|
||||||
|
|
||||||
See [Generation Modes](/docs/generation-modes) for full Chat mode setup.
|
See [Generation Modes](/docs/generation-modes) for full Inline mode setup.
|
||||||
|
|
||||||
### SpecStream Types
|
### SpecStream Types
|
||||||
|
|
||||||
@@ -479,9 +481,10 @@ function buildUserPrompt(options: UserPromptOptions): string
|
|||||||
|
|
||||||
interface UserPromptOptions {
|
interface UserPromptOptions {
|
||||||
prompt: string; // The user's text prompt
|
prompt: string; // The user's text prompt
|
||||||
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
|
currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode)
|
||||||
state?: Record<string, unknown> | null; // Runtime state context to include
|
state?: Record<string, unknown> | null; // Runtime state context to include
|
||||||
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
|
||||||
|
editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"])
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -491,14 +494,15 @@ interface UserPromptOptions {
|
|||||||
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
|
||||||
```
|
```
|
||||||
|
|
||||||
### Refinement (patch-only mode)
|
### Refinement (edit modes)
|
||||||
|
|
||||||
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
|
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
|
```typescript
|
||||||
const userPrompt = buildUserPrompt({
|
const userPrompt = buildUserPrompt({
|
||||||
prompt: "add a dark mode toggle",
|
prompt: "add a dark mode toggle",
|
||||||
currentSpec: existingSpec,
|
currentSpec: existingSpec,
|
||||||
|
editModes: ["patch", "merge"],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -513,6 +517,93 @@ const userPrompt = buildUserPrompt({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 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
|
## evaluateVisibility
|
||||||
|
|
||||||
Evaluates a visibility condition against the state model.
|
Evaluates a visibility condition against the state model.
|
||||||
|
|||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/image")
|
||||||
|
|
||||||
|
# @json-render/image
|
||||||
|
|
||||||
|
Image renderer. Turn JSON specs into SVG and PNG images using [Satori](https://github.com/vercel/satori).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/core @json-render/image
|
||||||
|
```
|
||||||
|
|
||||||
|
For PNG output, also install the optional peer dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @resvg/resvg-js
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Image example](https://github.com/vercel-labs/json-render/tree/main/examples/image) for a full working example.
|
||||||
|
|
||||||
|
## schema
|
||||||
|
|
||||||
|
The image element schema for image specs. Use with `defineCatalog` from core.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/image';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Render Functions
|
||||||
|
|
||||||
|
Server-side functions for producing image output. Both accept a spec and optional `RenderOptions`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderToSvg, renderToPng } from '@json-render/image/render';
|
||||||
|
|
||||||
|
const svg = await renderToSvg(spec, { fonts });
|
||||||
|
|
||||||
|
const png = await renderToPng(spec, { fonts });
|
||||||
|
await writeFile('output.png', png);
|
||||||
|
```
|
||||||
|
|
||||||
|
### RenderOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RenderOptions {
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
fonts?: SatoriOptions['fonts'];
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>fonts</code></td>
|
||||||
|
<td><code>{"SatoriOptions['fonts']"}</code></td>
|
||||||
|
<td><code>[]</code></td>
|
||||||
|
<td>Font data for text rendering (required for meaningful output)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>width</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td>Frame prop</td>
|
||||||
|
<td>Override the output image width</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>height</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td>Frame prop</td>
|
||||||
|
<td>Override the output image height</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>registry</code></td>
|
||||||
|
<td><code>{"Record<string, ComponentRenderer>"}</code></td>
|
||||||
|
<td><code>{"{}"}</code></td>
|
||||||
|
<td>Custom component map (merged with standard components)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>includeStandard</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td><code>true</code></td>
|
||||||
|
<td>Include built-in standard components</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>state</code></td>
|
||||||
|
<td><code>{"Record<string, unknown>"}</code></td>
|
||||||
|
<td><code>{"{}"}</code></td>
|
||||||
|
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Standard Components
|
||||||
|
|
||||||
|
### Root
|
||||||
|
|
||||||
|
#### Frame
|
||||||
|
|
||||||
|
Root image container. Defines the output image dimensions and background. Must be the root element.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
padding: number | null;
|
||||||
|
display: "flex" | "none" | null;
|
||||||
|
flexDirection: "row" | "column" | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
#### Box
|
||||||
|
|
||||||
|
Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
padding: number | null;
|
||||||
|
paddingTop: number | null;
|
||||||
|
paddingBottom: number | null;
|
||||||
|
paddingLeft: number | null;
|
||||||
|
paddingRight: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
borderWidth: number | null;
|
||||||
|
borderColor: string | null;
|
||||||
|
borderRadius: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
width: number | string | null;
|
||||||
|
height: number | string | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
flexDirection: "row" | "column" | null;
|
||||||
|
position: "relative" | "absolute" | null;
|
||||||
|
top: number | null;
|
||||||
|
left: number | null;
|
||||||
|
right: number | null;
|
||||||
|
bottom: number | null;
|
||||||
|
overflow: "visible" | "hidden" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Row
|
||||||
|
|
||||||
|
Horizontal flex layout with optional wrapping.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
wrap: boolean | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Column
|
||||||
|
|
||||||
|
Vertical flex layout.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
#### Heading
|
||||||
|
|
||||||
|
Heading text at various levels. h1 is largest, h4 is smallest.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
level: "h1" | "h2" | "h3" | "h4" | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
letterSpacing: number | string | null;
|
||||||
|
lineHeight: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text
|
||||||
|
|
||||||
|
Body text with configurable size, color, weight, and alignment.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
fontWeight: "normal" | "bold" | null;
|
||||||
|
fontStyle: "normal" | "italic" | null;
|
||||||
|
lineHeight: number | null;
|
||||||
|
letterSpacing: number | string | null;
|
||||||
|
textDecoration: "none" | "underline" | "line-through" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image
|
||||||
|
|
||||||
|
Image from a URL with optional dimensions and fit.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
src: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
borderRadius: number | null;
|
||||||
|
objectFit: "contain" | "cover" | "fill" | "none" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decorative
|
||||||
|
|
||||||
|
#### Divider
|
||||||
|
|
||||||
|
Horizontal line separator.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
color: string | null;
|
||||||
|
thickness: number | null;
|
||||||
|
marginTop: number | null;
|
||||||
|
marginBottom: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Spacer
|
||||||
|
|
||||||
|
Empty vertical space.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Catalog Definitions
|
||||||
|
|
||||||
|
Pre-built definitions for creating image catalogs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { standardComponentDefinitions } from '@json-render/image/catalog';
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema } from '@json-render/image';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: {
|
||||||
|
...standardComponentDefinitions,
|
||||||
|
// Add custom components
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server-Safe Import
|
||||||
|
|
||||||
|
Import schema and catalog definitions without pulling in React or Satori:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/image/server';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-path Exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/image</code></td>
|
||||||
|
<td>Full package: schema, renderer, components, render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/image/server</code></td>
|
||||||
|
<td>Schema and catalog definitions only (no React or Satori)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/image/catalog</code></td>
|
||||||
|
<td>Standard component definitions and types</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/image/render</code></td>
|
||||||
|
<td>Server-side render functions only</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>ImageSchema</code></td>
|
||||||
|
<td>Schema type for image specs</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ImageSpec</code></td>
|
||||||
|
<td>Spec type for image output</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RenderOptions</code></td>
|
||||||
|
<td>Options for render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentRenderProps</code></td>
|
||||||
|
<td>Props passed to component render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentRenderer</code></td>
|
||||||
|
<td>Component render function type</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentRegistry</code></td>
|
||||||
|
<td>Map of component names to render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentDefinitions</code></td>
|
||||||
|
<td>Type of the standard component definitions object</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentProps{'<K>'}</code></td>
|
||||||
|
<td>Inferred props type for a standard component by name</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/jotai")
|
||||||
|
|
||||||
|
# @json-render/jotai
|
||||||
|
|
||||||
|
Jotai adapter for json-render's `StateStore` interface.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/jotai @json-render/core @json-render/react jotai
|
||||||
|
```
|
||||||
|
|
||||||
|
## jotaiStateStore
|
||||||
|
|
||||||
|
Create a `StateStore` backed by a Jotai atom.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Required</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>atom</code></td>
|
||||||
|
<td><code>{'WritableAtom<StateModel, [StateModel], void>'}</code></td>
|
||||||
|
<td>Yes</td>
|
||||||
|
<td>A writable atom holding the state model.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td>Jotai <code>Store</code></td>
|
||||||
|
<td>No</td>
|
||||||
|
<td>The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with <code>{'<Provider>'}</code>.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
const store = jotaiStateStore({ atom: uiAtom });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through Jotai */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Shared Jotai Store
|
||||||
|
|
||||||
|
If your app already uses a Jotai `<Provider>` with a custom store, pass it so both json-render and your components share the same state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { atom, createStore } from "jotai";
|
||||||
|
import { Provider as JotaiProvider } from "jotai/react";
|
||||||
|
import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const jStore = createStore();
|
||||||
|
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
const store = jotaiStateStore({ atom: uiAtom, store: jStore });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<JotaiProvider store={jStore}>
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* Both json-render and useAtom() see the same state */}
|
||||||
|
</StateProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td><code>@json-render/core</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/mcp")
|
||||||
|
|
||||||
|
# @json-render/mcp
|
||||||
|
|
||||||
|
MCP Apps integration for json-render. Serve json-render UIs as interactive [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) inside Claude, ChatGPT, Cursor, VS Code, and other MCP-capable clients.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/mcp @json-render/core @modelcontextprotocol/sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
For the iframe-side React UI, also install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/react react react-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [MCP example](https://github.com/vercel-labs/json-render/tree/main/examples/mcp) for a full working example.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
MCP Apps let MCP servers return interactive HTML UIs that render directly inside chat conversations. `@json-render/mcp` bridges json-render catalogs with the MCP Apps protocol:
|
||||||
|
|
||||||
|
1. Your **catalog** defines which components and actions the AI can use
|
||||||
|
2. The **MCP server** exposes the catalog as a tool with the spec schema
|
||||||
|
3. The **bundled HTML** renders json-render specs inside the host's sandboxed iframe
|
||||||
|
4. The AI generates a spec, the host renders it, and users interact with the live UI
|
||||||
|
|
||||||
|
## Server API
|
||||||
|
|
||||||
|
### createMcpApp
|
||||||
|
|
||||||
|
Create a fully-configured MCP server. This is the main entry point.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createMcpApp } from "@json-render/mcp";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const server = createMcpApp({
|
||||||
|
name: "My Dashboard",
|
||||||
|
version: "1.0.0",
|
||||||
|
catalog: myCatalog,
|
||||||
|
html: fs.readFileSync("dist/index.html", "utf-8"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CreateMcpAppOptions
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>name</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td>Server name shown in client UIs</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>version</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td>Server version</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>catalog</code></td>
|
||||||
|
<td><code>Catalog</code></td>
|
||||||
|
<td>json-render catalog defining available components</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>html</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td>Self-contained HTML for the iframe UI</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>tool</code></td>
|
||||||
|
<td><code>McpToolOptions</code></td>
|
||||||
|
<td>Optional tool name/title/description overrides</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### registerJsonRenderTool
|
||||||
|
|
||||||
|
Register a json-render tool on an existing `McpServer`. Use this when you need to add json-render to a server that has other tools.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { registerJsonRenderTool } from "@json-render/mcp";
|
||||||
|
|
||||||
|
registerJsonRenderTool(server, {
|
||||||
|
catalog,
|
||||||
|
name: "render-ui",
|
||||||
|
title: "Render UI",
|
||||||
|
description: "Render an interactive UI",
|
||||||
|
resourceUri: "ui://render-ui/view.html",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### registerJsonRenderResource
|
||||||
|
|
||||||
|
Register the UI resource that serves the bundled HTML.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { registerJsonRenderResource } from "@json-render/mcp";
|
||||||
|
|
||||||
|
registerJsonRenderResource(server, {
|
||||||
|
resourceUri: "ui://render-ui/view.html",
|
||||||
|
html: bundledHtml,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client API (`@json-render/mcp/app`)
|
||||||
|
|
||||||
|
These exports run inside the sandboxed iframe rendered by the MCP host.
|
||||||
|
|
||||||
|
### useJsonRenderApp
|
||||||
|
|
||||||
|
React hook that connects to the MCP host, listens for tool results, and maintains the current json-render spec.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useJsonRenderApp } from "@json-render/mcp/app";
|
||||||
|
import { JSONUIProvider, Renderer } from "@json-render/react";
|
||||||
|
|
||||||
|
function McpAppView({ registry }) {
|
||||||
|
const { spec, loading, connected, error } = useJsonRenderApp({
|
||||||
|
name: "my-app",
|
||||||
|
version: "1.0.0",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return <div>Error: {error.message}</div>;
|
||||||
|
if (!spec) return <div>Waiting...</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
|
||||||
|
<Renderer spec={spec} registry={registry} loading={loading} />
|
||||||
|
</JSONUIProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UseJsonRenderAppReturn
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Field</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>spec</code></td>
|
||||||
|
<td><code>{'Spec | null'}</code></td>
|
||||||
|
<td>Current json-render spec</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>loading</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td>Whether the spec is still being received</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>connected</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td>Whether connected to the host</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>connecting</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td>Whether currently connecting</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>error</code></td>
|
||||||
|
<td><code>{'Error | null'}</code></td>
|
||||||
|
<td>Connection error, if any</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>app</code></td>
|
||||||
|
<td><code>{'App | null'}</code></td>
|
||||||
|
<td>The underlying MCP App instance</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>callServerTool</code></td>
|
||||||
|
<td><code>{'(name, args?) => Promise<void>'}</code></td>
|
||||||
|
<td>Call an MCP server tool and update spec from result</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### buildAppHtml
|
||||||
|
|
||||||
|
Generate a self-contained HTML page from bundled JavaScript and CSS.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildAppHtml } from "@json-render/mcp/app";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
const html = buildAppHtml({
|
||||||
|
title: "Dashboard",
|
||||||
|
js: fs.readFileSync("dist/app.js", "utf-8"),
|
||||||
|
css: fs.readFileSync("dist/app.css", "utf-8"),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
### Cursor
|
||||||
|
|
||||||
|
Add to `.cursor/mcp.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"json-render": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["tsx", "path/to/server.ts", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Claude Desktop
|
||||||
|
|
||||||
|
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"json-render": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["tsx", "/absolute/path/to/server.ts", "--stdio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Clients
|
||||||
|
|
||||||
|
MCP Apps are supported by Claude (web and desktop), ChatGPT, VS Code (GitHub Copilot), Cursor, Goose, and Postman.
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-email")
|
||||||
|
|
||||||
|
# @json-render/react-email
|
||||||
|
|
||||||
|
React Email renderer. Turn JSON specs into HTML or plain-text emails using `@react-email/components` and `@react-email/render`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [React Email example](https://github.com/vercel-labs/json-render/tree/main/examples/react-email) for a full working example.
|
||||||
|
|
||||||
|
## schema
|
||||||
|
|
||||||
|
The email element schema for specs. Use with `defineCatalog` from core.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-email';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Render Functions
|
||||||
|
|
||||||
|
Server-side functions for producing email output. All accept a spec and optional `RenderOptions`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderToHtml, renderToPlainText } from '@json-render/react-email';
|
||||||
|
|
||||||
|
const html = await renderToHtml(spec);
|
||||||
|
|
||||||
|
const plainText = await renderToPlainText(spec);
|
||||||
|
```
|
||||||
|
|
||||||
|
### RenderOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RenderOptions {
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>registry</code></td>
|
||||||
|
<td>Custom component map (merged with standard components)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>includeStandard</code></td>
|
||||||
|
<td>Include built-in standard components (default: <code>true</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>state</code></td>
|
||||||
|
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry } from '@json-render/react-email';
|
||||||
|
import { Container, Heading, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) => (
|
||||||
|
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
|
||||||
|
<Heading>{props.title}</Heading>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await renderToHtml(spec, { registry });
|
||||||
|
```
|
||||||
|
|
||||||
|
## createRenderer
|
||||||
|
|
||||||
|
Create a standalone renderer component wired to state, actions, and validation (for interactive previews in the browser).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createRenderer } from '@json-render/react-email';
|
||||||
|
|
||||||
|
const EmailRenderer = createRenderer(catalog, components);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
The main component that renders a spec to React Email elements. Use inside `JSONUIProvider` when you need state, actions, or visibility.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: ComponentRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Components
|
||||||
|
|
||||||
|
### Document structure
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Html</code></td>
|
||||||
|
<td>Top-level email wrapper. Must be the root element.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Head</code></td>
|
||||||
|
<td>Email head section. Place inside Html.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Body</code></td>
|
||||||
|
<td>Email body wrapper. Place inside Html.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Container</code></td>
|
||||||
|
<td>Constrains content width (e.g. max-width 600px).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Section</code></td>
|
||||||
|
<td>Groups related content.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Row</code></td>
|
||||||
|
<td>Horizontal layout row.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Column</code></td>
|
||||||
|
<td>Column within a Row.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Heading</code></td>
|
||||||
|
<td>Heading text (h1-h6).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Text</code></td>
|
||||||
|
<td>Body text paragraph.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Link</code></td>
|
||||||
|
<td>Hyperlink with text and href.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Button</code></td>
|
||||||
|
<td>Call-to-action button (link styled as button).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Image</code></td>
|
||||||
|
<td>Image from URL.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Hr</code></td>
|
||||||
|
<td>Horizontal rule separator.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Utility
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Preview</code></td>
|
||||||
|
<td>Preview text for inbox (inside Html).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Markdown</code></td>
|
||||||
|
<td>Renders markdown content as email-safe HTML.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Server-Safe Import
|
||||||
|
|
||||||
|
Import schema and catalog definitions without pulling in React or `@react-email/components`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-email/server';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-path Exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-email</code></td>
|
||||||
|
<td>Full package: schema, renderer, components, render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-email/server</code></td>
|
||||||
|
<td>Schema and catalog definitions only (no React)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-email/catalog</code></td>
|
||||||
|
<td>Standard component definitions and types</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-email/render</code></td>
|
||||||
|
<td>Server-side render functions only</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactEmailSchema</code></td>
|
||||||
|
<td>Schema type for email specs</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactEmailSpec</code></td>
|
||||||
|
<td>Spec type for email documents</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RenderOptions</code></td>
|
||||||
|
<td>Options for render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentContext</code></td>
|
||||||
|
<td>Typed component render function context</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentFn</code></td>
|
||||||
|
<td>Component render function type</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentDefinitions</code></td>
|
||||||
|
<td>Type of the standard component definitions object</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentProps<K></code></td>
|
||||||
|
<td>Inferred props type for a standard component by name</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/react-native API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-native")
|
||||||
|
|
||||||
# @json-render/react-native
|
# @json-render/react-native
|
||||||
|
|
||||||
@@ -8,65 +9,120 @@ React Native renderer with standard components, providers, and hooks.
|
|||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
| Component | Props | Description |
|
<table>
|
||||||
|-----------|-------|-------------|
|
<thead>
|
||||||
| `Container` | `padding`, `background`, `borderRadius`, `borderColor`, `flex` | Basic wrapper with styling |
|
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||||
| `Row` | `gap`, `align`, `justify`, `flex`, `wrap` | Horizontal flex layout |
|
</thead>
|
||||||
| `Column` | `gap`, `align`, `justify`, `flex` | Vertical flex layout |
|
<tbody>
|
||||||
| `ScrollContainer` | `direction` | Scrollable area (vertical or horizontal) |
|
<tr><td><code>Container</code></td><td><code>padding</code>, <code>background</code>, <code>borderRadius</code>, <code>borderColor</code>, <code>flex</code></td><td>Basic wrapper with styling</td></tr>
|
||||||
| `SafeArea` | `edges` | Safe area insets for notch/home indicator |
|
<tr><td><code>Row</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code>, <code>wrap</code></td><td>Horizontal flex layout</td></tr>
|
||||||
| `Pressable` | `action`, `actionParams` | Touchable wrapper that triggers actions |
|
<tr><td><code>Column</code></td><td><code>gap</code>, <code>align</code>, <code>justify</code>, <code>flex</code></td><td>Vertical flex layout</td></tr>
|
||||||
| `Spacer` | `size`, `flex` | Fixed or flexible spacing |
|
<tr><td><code>ScrollContainer</code></td><td><code>direction</code></td><td>Scrollable area (vertical or horizontal)</td></tr>
|
||||||
| `Divider` | `color`, `thickness` | Thin line separator |
|
<tr><td><code>SafeArea</code></td><td><code>edges</code></td><td>Safe area insets for notch/home indicator</td></tr>
|
||||||
|
<tr><td><code>Pressable</code></td><td><code>action</code>, <code>actionParams</code></td><td>Touchable wrapper that triggers actions</td></tr>
|
||||||
|
<tr><td><code>Spacer</code></td><td><code>size</code>, <code>flex</code></td><td>Fixed or flexible spacing</td></tr>
|
||||||
|
<tr><td><code>Divider</code></td><td><code>color</code>, <code>thickness</code></td><td>Thin line separator</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
|
|
||||||
| Component | Props | Description |
|
<table>
|
||||||
|-----------|-------|-------------|
|
<thead>
|
||||||
| `Heading` | `text`, `level`, `align`, `color` | Heading text (levels 1-6) |
|
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||||
| `Paragraph` | `text`, `align`, `color` | Body text |
|
</thead>
|
||||||
| `Label` | `text`, `color`, `bold` | Small label text |
|
<tbody>
|
||||||
| `Image` | `uri`, `width`, `height`, `resizeMode`, `borderRadius` | Image display |
|
<tr><td><code>Heading</code></td><td><code>text</code>, <code>level</code>, <code>align</code>, <code>color</code></td><td>Heading text (levels 1-6)</td></tr>
|
||||||
| `Avatar` | `uri`, `size`, `fallback` | Circular avatar |
|
<tr><td><code>Paragraph</code></td><td><code>text</code>, <code>align</code>, <code>color</code></td><td>Body text</td></tr>
|
||||||
| `Badge` | `label`, `color`, `textColor` | Status badge |
|
<tr><td><code>Label</code></td><td><code>text</code>, <code>color</code>, <code>bold</code></td><td>Small label text</td></tr>
|
||||||
| `Chip` | `label`, `selected`, `color` | Tag/chip |
|
<tr><td><code>Image</code></td><td><code>uri</code>, <code>width</code>, <code>height</code>, <code>resizeMode</code>, <code>borderRadius</code></td><td>Image display</td></tr>
|
||||||
|
<tr><td><code>Avatar</code></td><td><code>uri</code>, <code>size</code>, <code>fallback</code></td><td>Circular avatar</td></tr>
|
||||||
|
<tr><td><code>Badge</code></td><td><code>label</code>, <code>color</code>, <code>textColor</code></td><td>Status badge</td></tr>
|
||||||
|
<tr><td><code>Chip</code></td><td><code>label</code>, <code>selected</code>, <code>color</code></td><td>Tag/chip</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Input
|
### Input
|
||||||
|
|
||||||
| Component | Props | Description |
|
<table>
|
||||||
|-----------|-------|-------------|
|
<thead>
|
||||||
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
|
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||||
| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
|
</thead>
|
||||||
| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
|
<tbody>
|
||||||
| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
|
<tr><td><code>Button</code></td><td><code>label</code>, <code>variant</code>, <code>size</code>, <code>disabled</code>, <code>action</code>, <code>actionParams</code></td><td>Pressable button</td></tr>
|
||||||
| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
|
<tr><td><code>TextInput</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>), <code>secure</code>, <code>keyboardType</code>, <code>multiline</code></td><td>Text input field</td></tr>
|
||||||
| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
|
<tr><td><code>Switch</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Toggle switch</td></tr>
|
||||||
|
<tr><td><code>Checkbox</code></td><td><code>checked</code> (use <code>$bindState</code>), <code>label</code></td><td>Checkbox with label</td></tr>
|
||||||
|
<tr><td><code>Slider</code></td><td><code>value</code> (use <code>$bindState</code>), <code>min</code>, <code>max</code>, <code>step</code></td><td>Range slider</td></tr>
|
||||||
|
<tr><td><code>SearchBar</code></td><td><code>placeholder</code>, <code>value</code> (use <code>$bindState</code>)</td><td>Search input</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Feedback
|
### Feedback
|
||||||
|
|
||||||
| Component | Props | Description |
|
<table>
|
||||||
|-----------|-------|-------------|
|
<thead>
|
||||||
| `Spinner` | `size`, `color` | Loading indicator |
|
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||||
| `ProgressBar` | `progress`, `color`, `trackColor` | Progress indicator |
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>Spinner</code></td><td><code>size</code>, <code>color</code></td><td>Loading indicator</td></tr>
|
||||||
|
<tr><td><code>ProgressBar</code></td><td><code>progress</code>, <code>color</code>, <code>trackColor</code></td><td>Progress indicator</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Composite
|
### Composite
|
||||||
|
|
||||||
| Component | Props | Description |
|
<table>
|
||||||
|-----------|-------|-------------|
|
<thead>
|
||||||
| `Card` | `title`, `subtitle`, `padding` | Card container |
|
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
|
||||||
| `ListItem` | `title`, `subtitle`, `leading`, `trailing`, `action`, `actionParams` | List row |
|
</thead>
|
||||||
| `Modal` | `visible`, `title` | Bottom sheet modal |
|
<tbody>
|
||||||
|
<tr><td><code>Card</code></td><td><code>title</code>, <code>subtitle</code>, <code>padding</code></td><td>Card container</td></tr>
|
||||||
|
<tr><td><code>ListItem</code></td><td><code>title</code>, <code>subtitle</code>, <code>leading</code>, <code>trailing</code>, <code>action</code>, <code>actionParams</code></td><td>List row</td></tr>
|
||||||
|
<tr><td><code>Modal</code></td><td><code>visible</code>, <code>title</code></td><td>Bottom sheet modal</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Providers
|
## Providers
|
||||||
|
|
||||||
### StateProvider
|
### StateProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<StateProvider initialState={object}>
|
<StateProvider initialState={object} onStateChange={fn}>
|
||||||
{children}
|
{children}
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
|
||||||
|
<tr><td><code>initialState</code></td><td><code>Record<string, unknown></code></td><td>Initial state model (uncontrolled mode).</td></tr>
|
||||||
|
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||||
|
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — components re-render automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||||
|
|
||||||
### ActionProvider
|
### ActionProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -164,8 +220,13 @@ import { standardComponentDefinitions, standardActionDefinitions } from "@json-r
|
|||||||
import { schema } from "@json-render/react-native/schema";
|
import { schema } from "@json-render/react-native/schema";
|
||||||
```
|
```
|
||||||
|
|
||||||
| Export | Purpose |
|
<table>
|
||||||
|--------|---------|
|
<thead>
|
||||||
| `standardComponentDefinitions` | Catalog definitions for all 25+ standard components |
|
<tr><th>Export</th><th>Purpose</th></tr>
|
||||||
| `standardActionDefinitions` | Catalog definitions for standard actions (setState, navigate) |
|
</thead>
|
||||||
| `schema` | React Native element tree schema |
|
<tbody>
|
||||||
|
<tr><td><code>standardComponentDefinitions</code></td><td>Catalog definitions for all 25+ standard components</td></tr>
|
||||||
|
<tr><td><code>standardActionDefinitions</code></td><td>Catalog definitions for standard actions (setState, navigate)</td></tr>
|
||||||
|
<tr><td><code>schema</code></td><td>React Native element tree schema</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-pdf")
|
||||||
|
|
||||||
|
# @json-render/react-pdf
|
||||||
|
|
||||||
|
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/core @json-render/react-pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
|
||||||
|
|
||||||
|
## schema
|
||||||
|
|
||||||
|
The PDF element schema for document specs. Use with `defineCatalog` from core.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Render Functions
|
||||||
|
|
||||||
|
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(spec);
|
||||||
|
|
||||||
|
const stream = await renderToStream(spec);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
await renderToFile(spec, './output.pdf');
|
||||||
|
```
|
||||||
|
|
||||||
|
### RenderOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RenderOptions {
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>registry</code></td>
|
||||||
|
<td>Custom component map (merged with standard components)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>includeStandard</code></td>
|
||||||
|
<td>Include built-in standard components (default: <code>true</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>state</code></td>
|
||||||
|
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry } from '@json-render/react-pdf';
|
||||||
|
import { View, Text } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Badge: ({ props }) => (
|
||||||
|
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
|
||||||
|
<Text style={{ fontSize: 10 }}>{props.label}</Text>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(spec, { registry });
|
||||||
|
```
|
||||||
|
|
||||||
|
## createRenderer
|
||||||
|
|
||||||
|
Create a standalone renderer component wired to state, actions, and validation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createRenderer } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const PDFRenderer = createRenderer(catalog, components);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateRendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
store?: StateStore;
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: ComponentRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
The main component that renders a spec to `@react-pdf/renderer` elements.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: ComponentRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Components
|
||||||
|
|
||||||
|
### Document Structure
|
||||||
|
|
||||||
|
#### Document
|
||||||
|
|
||||||
|
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title: string | null;
|
||||||
|
author: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page
|
||||||
|
|
||||||
|
A page in the document with configurable size, orientation, and margins.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
|
||||||
|
orientation: "portrait" | "landscape" | null;
|
||||||
|
marginTop: number | null;
|
||||||
|
marginBottom: number | null;
|
||||||
|
marginLeft: number | null;
|
||||||
|
marginRight: number | null;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
#### View
|
||||||
|
|
||||||
|
Generic container with padding, margin, background, border, and flex alignment.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
padding: number | null;
|
||||||
|
paddingTop: number | null;
|
||||||
|
paddingBottom: number | null;
|
||||||
|
paddingLeft: number | null;
|
||||||
|
paddingRight: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
borderWidth: number | null;
|
||||||
|
borderColor: string | null;
|
||||||
|
borderRadius: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Row
|
||||||
|
|
||||||
|
Horizontal flex layout with optional wrapping.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
wrap: boolean | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Column
|
||||||
|
|
||||||
|
Vertical flex layout.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
#### Heading
|
||||||
|
|
||||||
|
h1-h4 heading text with configurable color and alignment.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
level: "h1" | "h2" | "h3" | "h4" | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text
|
||||||
|
|
||||||
|
Body text with full styling control.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
fontWeight: "normal" | "bold" | null;
|
||||||
|
fontStyle: "normal" | "italic" | null;
|
||||||
|
lineHeight: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image
|
||||||
|
|
||||||
|
Image from a URL with optional dimensions and fit.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
src: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
objectFit: "contain" | "cover" | "fill" | "none" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Link
|
||||||
|
|
||||||
|
Hyperlink with visible text.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
#### Table
|
||||||
|
|
||||||
|
Data table with typed columns and string rows. Supports header styling and striped rows.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
|
||||||
|
rows: string[][];
|
||||||
|
headerBackgroundColor: string | null;
|
||||||
|
headerTextColor: string | null;
|
||||||
|
borderColor: string | null;
|
||||||
|
fontSize: number | null;
|
||||||
|
striped: boolean | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List
|
||||||
|
|
||||||
|
Ordered or unordered list.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
items: string[];
|
||||||
|
ordered: boolean | null;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
spacing: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decorative
|
||||||
|
|
||||||
|
#### Divider
|
||||||
|
|
||||||
|
Horizontal line separator.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
color: string | null;
|
||||||
|
thickness: number | null;
|
||||||
|
marginTop: number | null;
|
||||||
|
marginBottom: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Spacer
|
||||||
|
|
||||||
|
Empty vertical space.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page-Level
|
||||||
|
|
||||||
|
#### PageNumber
|
||||||
|
|
||||||
|
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
format: string | null; // default: "{pageNumber} / {totalPages}"
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||||
|
|
||||||
|
const store = createStateStore({ invoice: { total: 100 } });
|
||||||
|
store.set("/invoice/total", 200);
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
|
||||||
|
|
||||||
|
## Server-Safe Import
|
||||||
|
|
||||||
|
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-path Exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf</code></td>
|
||||||
|
<td>Full package: schema, renderer, components, render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/server</code></td>
|
||||||
|
<td>Schema and catalog definitions only (no React)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/catalog</code></td>
|
||||||
|
<td>Standard component definitions and types</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/render</code></td>
|
||||||
|
<td>Server-side render functions only</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactPdfSchema</code></td>
|
||||||
|
<td>Schema type for PDF specs</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactPdfSpec</code></td>
|
||||||
|
<td>Spec type for PDF documents</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RenderOptions</code></td>
|
||||||
|
<td>Options for render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentContext</code></td>
|
||||||
|
<td>Typed component render function context</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentFn</code></td>
|
||||||
|
<td>Component render function type</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentDefinitions</code></td>
|
||||||
|
<td>Type of the standard component definitions object</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentProps<K></code></td>
|
||||||
|
<td>Inferred props type for a standard component by name</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-three-fiber")
|
||||||
|
|
||||||
|
# @json-render/react-three-fiber
|
||||||
|
|
||||||
|
React Three Fiber renderer for json-render. 19 built-in 3D components for meshes, lights, models, environments, text, cameras, and controls.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/react-three-fiber @json-render/core @json-render/react @react-three/fiber @react-three/drei three zod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Entry Point</th>
|
||||||
|
<th>Exports</th>
|
||||||
|
<th>Use For</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-three-fiber</code></td>
|
||||||
|
<td><code>threeComponents</code>, <code>ThreeRenderer</code>, <code>ThreeCanvas</code>, schemas</td>
|
||||||
|
<td>React Three Fiber implementations and renderer</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-three-fiber/catalog</code></td>
|
||||||
|
<td><code>threeComponentDefinitions</code></td>
|
||||||
|
<td>Catalog schemas (no R3F dependency, safe for server)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```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,
|
||||||
|
OrbitControls: threeComponentDefinitions.OrbitControls,
|
||||||
|
{'}'},
|
||||||
|
actions: {'{}'},
|
||||||
|
{'}'});
|
||||||
|
|
||||||
|
const {'{ registry }'} = defineRegistry(catalog, {'{'}
|
||||||
|
components: {'{'}
|
||||||
|
Box: threeComponents.Box,
|
||||||
|
Sphere: threeComponents.Sphere,
|
||||||
|
AmbientLight: threeComponents.AmbientLight,
|
||||||
|
DirectionalLight: threeComponents.DirectionalLight,
|
||||||
|
OrbitControls: threeComponents.OrbitControls,
|
||||||
|
{'}'},
|
||||||
|
{'}'});
|
||||||
|
```
|
||||||
|
|
||||||
|
### ThreeCanvas (convenience)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ThreeCanvas
|
||||||
|
spec={'{spec}'}
|
||||||
|
registry={'{registry}'}
|
||||||
|
shadows
|
||||||
|
camera={'{'}{'{ position: [5, 5, 5], fov: 50 }'}{'}'}
|
||||||
|
style={'{'}{'{ width: "100%", height: "100vh" }'}{'}'}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Manual Canvas Setup
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {'{ Canvas }'} from "@react-three/fiber";
|
||||||
|
import {'{ ThreeRenderer }'} from "@json-render/react-three-fiber";
|
||||||
|
|
||||||
|
<Canvas shadows>
|
||||||
|
<ThreeRenderer spec={'{spec}'} registry={'{registry}'} />
|
||||||
|
</Canvas>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Primitives
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Key Props</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Box</code></td>
|
||||||
|
<td>Box mesh (default 1x1x1)</td>
|
||||||
|
<td><code>width</code>, <code>height</code>, <code>depth</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Sphere</code></td>
|
||||||
|
<td>Sphere mesh</td>
|
||||||
|
<td><code>radius</code>, <code>widthSegments</code>, <code>heightSegments</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Cylinder</code></td>
|
||||||
|
<td>Cylinder mesh</td>
|
||||||
|
<td><code>radiusTop</code>, <code>radiusBottom</code>, <code>height</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Cone</code></td>
|
||||||
|
<td>Cone mesh</td>
|
||||||
|
<td><code>radius</code>, <code>height</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Torus</code></td>
|
||||||
|
<td>Torus (donut) mesh</td>
|
||||||
|
<td><code>radius</code>, <code>tube</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Plane</code></td>
|
||||||
|
<td>Flat plane mesh</td>
|
||||||
|
<td><code>width</code>, <code>height</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Capsule</code></td>
|
||||||
|
<td>Capsule mesh</td>
|
||||||
|
<td><code>radius</code>, <code>length</code>, <code>material</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
All primitives share: <code>position</code>, <code>rotation</code>, <code>scale</code>, <code>castShadow</code>, <code>receiveShadow</code>, <code>material</code>.
|
||||||
|
|
||||||
|
### Material Schema
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Property</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Default</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>color</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td><code>"#ffffff"</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>metalness</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td><code>0</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>roughness</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td><code>1</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>emissive</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td><code>"#000000"</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>emissiveIntensity</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td><code>1</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>opacity</code></td>
|
||||||
|
<td><code>number</code></td>
|
||||||
|
<td><code>1</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>transparent</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td><code>false</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>wireframe</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td><code>false</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Lights
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Key Props</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>AmbientLight</code></td>
|
||||||
|
<td>Uniform illumination</td>
|
||||||
|
<td><code>color</code>, <code>intensity</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DirectionalLight</code></td>
|
||||||
|
<td>Sunlight-style</td>
|
||||||
|
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>castShadow</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>PointLight</code></td>
|
||||||
|
<td>Radiates from a point</td>
|
||||||
|
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>distance</code>, <code>decay</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SpotLight</code></td>
|
||||||
|
<td>Cone of light</td>
|
||||||
|
<td><code>position</code>, <code>color</code>, <code>intensity</code>, <code>angle</code>, <code>penumbra</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Other Components
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Component</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Key Props</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Group</code></td>
|
||||||
|
<td>Container for children</td>
|
||||||
|
<td><code>position</code>, <code>rotation</code>, <code>scale</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Model</code></td>
|
||||||
|
<td>GLTF/GLB model loader</td>
|
||||||
|
<td><code>url</code>, <code>position</code>, <code>rotation</code>, <code>scale</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Environment</code></td>
|
||||||
|
<td>HDRI environment map</td>
|
||||||
|
<td><code>preset</code>, <code>background</code>, <code>blur</code>, <code>intensity</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Fog</code></td>
|
||||||
|
<td>Linear fog effect</td>
|
||||||
|
<td><code>color</code>, <code>near</code>, <code>far</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>GridHelper</code></td>
|
||||||
|
<td>Reference grid</td>
|
||||||
|
<td><code>size</code>, <code>divisions</code>, <code>color</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Text3D</code></td>
|
||||||
|
<td>3D text (SDF)</td>
|
||||||
|
<td><code>text</code>, <code>fontSize</code>, <code>color</code>, <code>anchorX</code>, <code>anchorY</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>PerspectiveCamera</code></td>
|
||||||
|
<td>Camera</td>
|
||||||
|
<td><code>position</code>, <code>fov</code>, <code>near</code>, <code>far</code>, <code>makeDefault</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>OrbitControls</code></td>
|
||||||
|
<td>Camera controls</td>
|
||||||
|
<td><code>enableDamping</code>, <code>enableZoom</code>, <code>autoRotate</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Shared Schemas
|
||||||
|
|
||||||
|
Reusable Zod schemas for custom 3D components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {'{ vector3Schema, materialSchema, transformProps, shadowProps }'} from "@json-render/react-three-fiber";
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>vector3Schema</code></td>
|
||||||
|
<td><code>z.tuple([z.number(), z.number(), z.number()])</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>materialSchema</code></td>
|
||||||
|
<td>Standard material props (color, metalness, roughness, etc.)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>transformProps</code></td>
|
||||||
|
<td><code>{'{ position, rotation, scale }'}</code> schema fields</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>shadowProps</code></td>
|
||||||
|
<td><code>{'{ castShadow, receiveShadow }'}</code> schema fields</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## ThreeRenderer
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>spec</code></td>
|
||||||
|
<td><code>Spec | null</code></td>
|
||||||
|
<td>The spec to render as a 3D scene</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>registry</code></td>
|
||||||
|
<td><code>ComponentRegistry</code></td>
|
||||||
|
<td>Component registry from <code>defineRegistry</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td>External state store (controlled mode)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>initialState</code></td>
|
||||||
|
<td><code>Record<string, unknown></code></td>
|
||||||
|
<td>Initial state (uncontrolled mode)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>handlers</code></td>
|
||||||
|
<td><code>Record<string, Function></code></td>
|
||||||
|
<td>Action handlers</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>loading</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td>Whether the spec is streaming</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>children</code></td>
|
||||||
|
<td><code>ReactNode</code></td>
|
||||||
|
<td>Additional R3F elements alongside the spec</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## ThreeCanvas
|
||||||
|
|
||||||
|
Extends <code>ThreeRendererProps</code> with Canvas options:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>shadows</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td>Enable shadow maps</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>camera</code></td>
|
||||||
|
<td><code>object</code></td>
|
||||||
|
<td>Default camera config (position, fov, etc.)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>className</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td>CSS class for the canvas container</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>style</code></td>
|
||||||
|
<td><code>CSSProperties</code></td>
|
||||||
|
<td>Inline styles for the canvas container</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Type Helpers
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type {'{ ThreeProps }'} from "@json-render/react-three-fiber";
|
||||||
|
|
||||||
|
type BoxProps = ThreeProps<"Box">;
|
||||||
|
type SphereProps = ThreeProps<"Sphere">;
|
||||||
|
```
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/react API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react")
|
||||||
|
|
||||||
# @json-render/react
|
# @json-render/react
|
||||||
|
|
||||||
@@ -9,11 +10,57 @@ React components, providers, and hooks.
|
|||||||
### StateProvider
|
### StateProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<StateProvider initialState={object}>
|
<StateProvider initialState={object} onStateChange={fn}>
|
||||||
{children}
|
{children}
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>initialState</code></td>
|
||||||
|
<td><code>Record<string, unknown></code></td>
|
||||||
|
<td>Initial state model (uncontrolled mode).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>onStateChange</code></td>
|
||||||
|
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
|
||||||
|
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react";
|
||||||
|
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — React re-renders automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||||
|
|
||||||
### ActionProvider
|
### ActionProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -83,6 +130,40 @@ const { registry } = defineRegistry(catalog, {
|
|||||||
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSONUIProvider
|
||||||
|
|
||||||
|
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>functions</code></td>
|
||||||
|
<td><code>Record<string, ComputedFunction></code></td>
|
||||||
|
<td>Named functions for <code>$computed</code> expressions in props</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<JSONUIProvider
|
||||||
|
spec={spec}
|
||||||
|
catalog={catalog}
|
||||||
|
handlers={{ submit: async () => { /* ... */ } }}
|
||||||
|
functions={{ fullName: (args) => `${args.first} ${args.last}` }}
|
||||||
|
>
|
||||||
|
<Renderer spec={spec} registry={registry} />
|
||||||
|
</JSONUIProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `functions` prop is also available on `createRenderer`.
|
||||||
|
|
||||||
### Component Props (via defineRegistry)
|
### Component Props (via defineRegistry)
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@@ -211,6 +292,15 @@ const {
|
|||||||
|
|
||||||
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||||
|
|
||||||
|
### useOptionalValidation
|
||||||
|
|
||||||
|
Non-throwing variant of `useValidation()`. Returns `null` when no `ValidationProvider` is present, instead of throwing. Useful in components that may or may not be rendered inside a validation context.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const validation = useOptionalValidation();
|
||||||
|
// ValidationContextValue | null
|
||||||
|
```
|
||||||
|
|
||||||
### useBoundProp
|
### useBoundProp
|
||||||
|
|
||||||
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
Two-way binding helper for `$bindState` / `$bindItem` expressions. Returns `[value, setValue]` where `setValue` writes back to the bound state path.
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/redux")
|
||||||
|
|
||||||
|
# @json-render/redux
|
||||||
|
|
||||||
|
Redux / Redux Toolkit adapter for json-render's `StateStore` interface.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/redux @json-render/core @json-render/react redux
|
||||||
|
# or with Redux Toolkit (recommended):
|
||||||
|
npm install @json-render/redux @json-render/core @json-render/react @reduxjs/toolkit
|
||||||
|
```
|
||||||
|
|
||||||
|
## reduxStateStore
|
||||||
|
|
||||||
|
Create a `StateStore` backed by a Redux store.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { reduxStateStore } from "@json-render/redux";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Required</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td><code>Store</code></td>
|
||||||
|
<td>Yes</td>
|
||||||
|
<td>The Redux store instance.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>selector</code></td>
|
||||||
|
<td><code>{'(state: S) => StateModel'}</code></td>
|
||||||
|
<td>No</td>
|
||||||
|
<td>Select the json-render slice from the Redux state tree. Defaults to <code>{'(state) => state'}</code>.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>dispatch</code></td>
|
||||||
|
<td><code>{'(nextState: StateModel, store: Store) => void'}</code></td>
|
||||||
|
<td>Yes</td>
|
||||||
|
<td>Dispatch an action that replaces the selected slice with the next state.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { configureStore, createSlice } from "@reduxjs/toolkit";
|
||||||
|
import { reduxStateStore } from "@json-render/redux";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const uiSlice = createSlice({
|
||||||
|
name: "ui",
|
||||||
|
initialState: { count: 0 } as Record<string, unknown>,
|
||||||
|
reducers: {
|
||||||
|
replaceUiState: (_state, action) => action.payload,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reduxStore = configureStore({
|
||||||
|
reducer: { ui: uiSlice.reducer },
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = reduxStateStore({
|
||||||
|
store: reduxStore,
|
||||||
|
selector: (state) => state.ui,
|
||||||
|
dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through Redux */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td><code>@json-render/core</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/remotion API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/remotion")
|
||||||
|
|
||||||
# @json-render/remotion
|
# @json-render/remotion
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/shadcn API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/shadcn")
|
||||||
|
|
||||||
# @json-render/shadcn
|
# @json-render/shadcn
|
||||||
|
|
||||||
@@ -14,10 +15,27 @@ Your app must have Tailwind CSS configured.
|
|||||||
|
|
||||||
## Entry Points
|
## Entry Points
|
||||||
|
|
||||||
| Entry Point | Exports | Use For |
|
<table>
|
||||||
|-------------|---------|---------|
|
<thead>
|
||||||
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
|
<tr>
|
||||||
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
|
<th>Entry Point</th>
|
||||||
|
<th>Exports</th>
|
||||||
|
<th>Use For</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/shadcn</code></td>
|
||||||
|
<td><code>shadcnComponents</code></td>
|
||||||
|
<td>React implementations</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/shadcn/catalog</code></td>
|
||||||
|
<td><code>shadcnComponentDefinitions</code></td>
|
||||||
|
<td>Catalog schemas (no React dependency, safe for server)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -102,69 +120,225 @@ const { registry } = defineRegistry(catalog, {
|
|||||||
|
|
||||||
### Layout
|
### Layout
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Card` | Container card with optional title, description, maxWidth, centered |
|
<tr>
|
||||||
| `Stack` | Flex container with direction, gap, align, justify |
|
<th>Component</th>
|
||||||
| `Grid` | Grid layout with columns (1-6) and gap |
|
<th>Description</th>
|
||||||
| `Separator` | Visual separator line with orientation |
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Card</code></td>
|
||||||
|
<td>Container card with optional title, description, maxWidth, centered</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Stack</code></td>
|
||||||
|
<td>Flex container with direction, gap, align, justify</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Grid</code></td>
|
||||||
|
<td>Grid layout with columns (1-6) and gap</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Separator</code></td>
|
||||||
|
<td>Visual separator line with orientation</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
|
<tr>
|
||||||
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
|
<th>Component</th>
|
||||||
| `Collapsible` | Single collapsible section with title and defaultOpen |
|
<th>Description</th>
|
||||||
| `Pagination` | Page navigation with totalPages and page |
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Tabs</code></td>
|
||||||
|
<td>Tabbed navigation with tabs array, defaultValue, value</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Accordion</code></td>
|
||||||
|
<td>Collapsible sections with items array and type (single/multiple)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Collapsible</code></td>
|
||||||
|
<td>Single collapsible section with title and defaultOpen</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Pagination</code></td>
|
||||||
|
<td>Page navigation with totalPages and page</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Overlay
|
### Overlay
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Dialog` | Modal dialog with title, description, openPath |
|
<tr>
|
||||||
| `Drawer` | Bottom drawer with title, description, openPath |
|
<th>Component</th>
|
||||||
| `Tooltip` | Hover tooltip with content and text |
|
<th>Description</th>
|
||||||
| `Popover` | Click-triggered popover with trigger and content |
|
</tr>
|
||||||
| `DropdownMenu` | Dropdown menu with label and items array |
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Dialog</code></td>
|
||||||
|
<td>Modal dialog with title, description, openPath</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Drawer</code></td>
|
||||||
|
<td>Bottom drawer with title, description, openPath</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Tooltip</code></td>
|
||||||
|
<td>Hover tooltip with content and text</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Popover</code></td>
|
||||||
|
<td>Click-triggered popover with trigger and content</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DropdownMenu</code></td>
|
||||||
|
<td>Dropdown menu with label and items array</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Content
|
### Content
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Heading` | Heading text with level (h1-h4) |
|
<tr>
|
||||||
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
|
<th>Component</th>
|
||||||
| `Image` | Image with alt, width, height |
|
<th>Description</th>
|
||||||
| `Avatar` | User avatar with src, name, size |
|
</tr>
|
||||||
| `Badge` | Status badge with text and variant |
|
</thead>
|
||||||
| `Alert` | Alert banner with title, message, type |
|
<tbody>
|
||||||
| `Carousel` | Horizontally scrollable carousel with items |
|
<tr>
|
||||||
| `Table` | Data table with columns and rows |
|
<td><code>Heading</code></td>
|
||||||
|
<td>Heading text with level (h1-h4)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Text</code></td>
|
||||||
|
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Image</code></td>
|
||||||
|
<td>Image with alt, width, height</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Avatar</code></td>
|
||||||
|
<td>User avatar with src, name, size</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Badge</code></td>
|
||||||
|
<td>Status badge with text and variant</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Alert</code></td>
|
||||||
|
<td>Alert banner with title, message, type</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Carousel</code></td>
|
||||||
|
<td>Horizontally scrollable carousel with items</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Table</code></td>
|
||||||
|
<td>Data table with columns and rows</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Feedback
|
### Feedback
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Progress` | Progress bar with value, max, label |
|
<tr>
|
||||||
| `Skeleton` | Loading placeholder with width, height, rounded |
|
<th>Component</th>
|
||||||
| `Spinner` | Loading spinner with size and label |
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>Progress</code></td>
|
||||||
|
<td>Progress bar with value, max, label</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Skeleton</code></td>
|
||||||
|
<td>Loading placeholder with width, height, rounded</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Spinner</code></td>
|
||||||
|
<td>Loading spinner with size and label</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
### Input
|
### Input
|
||||||
|
|
||||||
| Component | Description |
|
<table>
|
||||||
|-----------|-------------|
|
<thead>
|
||||||
| `Button` | Clickable button with label, variant, disabled |
|
<tr>
|
||||||
| `Link` | Anchor link with label and href |
|
<th>Component</th>
|
||||||
| `Input` | Text input with label, name, type, placeholder, value, checks |
|
<th>Description</th>
|
||||||
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
|
</tr>
|
||||||
| `Select` | Dropdown select with label, name, options, value, checks |
|
</thead>
|
||||||
| `Checkbox` | Checkbox with label, name, checked |
|
<tbody>
|
||||||
| `Radio` | Radio button group with label, name, options, value |
|
<tr>
|
||||||
| `Switch` | Toggle switch with label, name, checked |
|
<td><code>Button</code></td>
|
||||||
| `Slider` | Range slider with label, min, max, step, value |
|
<td>Clickable button with label, variant, disabled</td>
|
||||||
| `Toggle` | Toggle button with label, pressed, variant |
|
</tr>
|
||||||
| `ToggleGroup` | Group of toggle buttons with items, type, value |
|
<tr>
|
||||||
| `ButtonGroup` | Group of buttons with buttons array and selected |
|
<td><code>Link</code></td>
|
||||||
|
<td>Anchor link with label and href</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Input</code></td>
|
||||||
|
<td>Text input with label, name, type, placeholder, value, checks</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Textarea</code></td>
|
||||||
|
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Select</code></td>
|
||||||
|
<td>Dropdown select with label, name, options, value, checks</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Checkbox</code></td>
|
||||||
|
<td>Checkbox with label, name, checked</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Radio</code></td>
|
||||||
|
<td>Radio button group with label, name, options, value</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Switch</code></td>
|
||||||
|
<td>Toggle switch with label, name, checked</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Slider</code></td>
|
||||||
|
<td>Range slider with label, min, max, step, value</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>Toggle</code></td>
|
||||||
|
<td>Toggle button with label, pressed, variant</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ToggleGroup</code></td>
|
||||||
|
<td>Group of toggle buttons with items, type, value</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ButtonGroup</code></td>
|
||||||
|
<td>Group of buttons with buttons array and selected</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata";
|
||||||
|
export const metadata = pageMetadata("docs/api/solid");
|
||||||
|
|
||||||
|
# @json-render/solid
|
||||||
|
|
||||||
|
SolidJS components, providers, and hooks for rendering json-render specs.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/solid" />
|
||||||
|
|
||||||
|
Peer dependencies: `solid-js ^1.9.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
<PackageInstall packages="solid-js zod" />
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### StateProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider
|
||||||
|
initialState={{}}
|
||||||
|
onStateChange={(changes) => console.log(changes)}
|
||||||
|
>
|
||||||
|
{/* children */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>store</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>StateStore</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
External store (controlled mode). When provided,{" "}
|
||||||
|
<code>initialState</code> and <code>onStateChange</code> are ignored.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>initialState</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>Record<string, unknown></code>
|
||||||
|
</td>
|
||||||
|
<td>Initial state model for uncontrolled mode.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<code>onStateChange</code>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<code>
|
||||||
|
{"(changes: Array<{ path: string; value: unknown }>) => void"}
|
||||||
|
</code>
|
||||||
|
</td>
|
||||||
|
<td>Called for uncontrolled state updates.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### ActionProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ActionProvider
|
||||||
|
handlers={{ submit: async (params) => {} }}
|
||||||
|
navigate={(path) => {}}
|
||||||
|
>
|
||||||
|
{/* children */}
|
||||||
|
</ActionProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### VisibilityProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<VisibilityProvider>{/* children */}</VisibilityProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ValidationProvider
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ValidationProvider customFunctions={{ custom: (value) => Boolean(value) }}>
|
||||||
|
{/* children */}
|
||||||
|
</ValidationProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### JSONUIProvider
|
||||||
|
|
||||||
|
Combined provider wrapper for state, visibility, validation, and actions.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<JSONUIProvider
|
||||||
|
registry={registry}
|
||||||
|
initialState={{}}
|
||||||
|
handlers={handlers}
|
||||||
|
validationFunctions={validationFunctions}
|
||||||
|
>
|
||||||
|
<Renderer spec={spec} registry={registry} />
|
||||||
|
</JSONUIProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a typed component registry and action helpers from a catalog.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { registry, handlers, executeAction } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: (renderProps) => <div>{renderProps.children}</div>,
|
||||||
|
Button: (renderProps) => (
|
||||||
|
<button onClick={() => renderProps.emit("press")}>
|
||||||
|
{renderProps.element.props.label as string}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
submit: async (params, setState, state) => {
|
||||||
|
// custom action logic
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Renderer spec={spec} registry={registry} loading={false} />
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a `Spec` tree using your registry.
|
||||||
|
|
||||||
|
### createRenderer
|
||||||
|
|
||||||
|
Build an app-level renderer from catalog + components:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const AppRenderer = createRenderer(catalog, {
|
||||||
|
Card: (renderProps) => <div>{renderProps.children}</div>,
|
||||||
|
});
|
||||||
|
|
||||||
|
<AppRenderer spec={spec} state={{}} onAction={(name, params) => {}} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hooks
|
||||||
|
|
||||||
|
- `useStateStore()`
|
||||||
|
- `useStateValue(path)` - returns an accessor
|
||||||
|
- `useStateBinding(path)` - returns `[Accessor<T | undefined>, setValue]`
|
||||||
|
- `useVisibility()` / `useIsVisible(condition)`
|
||||||
|
- `useActions()` / `useAction(binding)`
|
||||||
|
- `useValidation()` / `useOptionalValidation()`
|
||||||
|
- `useFieldValidation(path, config)` - returns accessor-backed `state`, `errors`, and `isValid`
|
||||||
|
- `useBoundProp(value, bindingPath)`
|
||||||
|
- `useUIStream(options)`
|
||||||
|
- `useChatUI(options)`
|
||||||
|
|
||||||
|
## Built-in Actions
|
||||||
|
|
||||||
|
`ActionProvider` handles these built-in actions:
|
||||||
|
|
||||||
|
- `setState`
|
||||||
|
- `pushState`
|
||||||
|
- `removeState`
|
||||||
|
- `validateForm`
|
||||||
|
|
||||||
|
## Component Props
|
||||||
|
|
||||||
|
Registry components receive:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||||
|
element: UIElement<string, P>;
|
||||||
|
children?: JSX.Element;
|
||||||
|
emit: (event: string) => void;
|
||||||
|
on: (event: string) => EventHandle;
|
||||||
|
bindings?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `emit("event")` to dispatch event bindings. Use `on("event")` to access `EventHandle` metadata (`bound`, `shouldPreventDefault`, `emit`).
|
||||||
|
|
||||||
|
## Reactivity Notes
|
||||||
|
|
||||||
|
- Keep changing reads in JSX expressions, `createMemo`, or `createEffect`.
|
||||||
|
- Avoid props destructuring in component signatures when you need live updates.
|
||||||
|
- `StateProvider` and other contexts expose getter-backed values so consumers read live signals.
|
||||||
|
- `useStateValue`, `useStateBinding`, and `useFieldValidation` expose reactive accessors; call them as functions.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/svelte")
|
||||||
|
|
||||||
|
# @json-render/svelte
|
||||||
|
|
||||||
|
Svelte 5 components, providers, and helpers for rendering json-render specs.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/svelte" />
|
||||||
|
|
||||||
|
Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
<PackageInstall packages="svelte zod" />
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<Renderer
|
||||||
|
spec={spec} // Spec | null
|
||||||
|
registry={registry}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Renders a spec with your component registry. If `spec` is `null`, it renders nothing.
|
||||||
|
|
||||||
|
### JsonUIProvider
|
||||||
|
|
||||||
|
Convenience wrapper around `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`.
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<JsonUIProvider
|
||||||
|
initialState={{}}
|
||||||
|
handlers={handlers}
|
||||||
|
validationFunctions={validationFunctions}
|
||||||
|
>
|
||||||
|
<Renderer {spec} {registry} />
|
||||||
|
</JsonUIProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a typed component registry and action handlers from a catalog.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineRegistry } from "@json-render/svelte";
|
||||||
|
|
||||||
|
const { registry, handlers, executeAction } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card,
|
||||||
|
Button,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
submit: async (params, setState, state) => {
|
||||||
|
// custom action logic
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
`handlers` is designed for `JsonUIProvider`/`ActionProvider`. `executeAction` is an imperative helper.
|
||||||
|
|
||||||
|
## Component Props
|
||||||
|
|
||||||
|
Registry components receive `BaseComponentProps<TProps>`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface BaseComponentProps<TProps> {
|
||||||
|
props: TProps;
|
||||||
|
children?: Snippet;
|
||||||
|
emit: (event: string) => void;
|
||||||
|
bindings?: Record<string, string>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `emit("eventName")` to trigger handlers declared in the spec `on` bindings.
|
||||||
|
|
||||||
|
## Context Helpers
|
||||||
|
|
||||||
|
Use these helpers inside Svelte components:
|
||||||
|
|
||||||
|
- `getStateValue(path)` - read/write state via `.current`
|
||||||
|
- `getBoundProp(() => value, () => bindingPath)` - write back resolved `$bindState` / `$bindItem` values
|
||||||
|
- `isVisible(condition)` - evaluate visibility via `.current`
|
||||||
|
- `getAction(name)` - read a registered action handler via `.current`
|
||||||
|
- `getFieldValidation(ctx, path, config)` - get field validation state + actions
|
||||||
|
|
||||||
|
For advanced usage, access full contexts:
|
||||||
|
|
||||||
|
- `getStateContext()`
|
||||||
|
- `getActionContext()`
|
||||||
|
- `getVisibilityContext()`
|
||||||
|
- `getValidationContext()`
|
||||||
|
- `getOptionalValidationContext()`
|
||||||
|
|
||||||
|
## Streaming
|
||||||
|
|
||||||
|
### createUIStream
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stream = createUIStream({
|
||||||
|
api: "/api/generate-ui",
|
||||||
|
onComplete: (spec) => console.log(spec),
|
||||||
|
});
|
||||||
|
|
||||||
|
await stream.send("Create a login form");
|
||||||
|
|
||||||
|
console.log(stream.spec);
|
||||||
|
console.log(stream.isStreaming);
|
||||||
|
```
|
||||||
|
|
||||||
|
### createChatUI
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const chat = createChatUI({ api: "/api/chat-ui" });
|
||||||
|
await chat.send("Build a settings panel");
|
||||||
|
console.log(chat.messages, chat.isStreaming);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schema Export
|
||||||
|
|
||||||
|
Use `schema` from `@json-render/svelte` when defining catalogs for Svelte specs.
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/vue")
|
||||||
|
|
||||||
|
# @json-render/vue
|
||||||
|
|
||||||
|
Vue 3 components, providers, and composables.
|
||||||
|
|
||||||
|
## Providers
|
||||||
|
|
||||||
|
### StateProvider
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StateProvider :initial-state="object" :on-state-change="fn">
|
||||||
|
<!-- children -->
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prop</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>initialState</code></td>
|
||||||
|
<td><code>Record<string, unknown></code></td>
|
||||||
|
<td>Initial state model (uncontrolled mode).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>onStateChange</code></td>
|
||||||
|
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
|
||||||
|
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/vue";
|
||||||
|
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
```
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<StateProvider :store="store">
|
||||||
|
<!-- children -->
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Mutate from anywhere — Vue re-renders automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActionProvider
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ActionProvider :handlers="Record<string, ActionHandler>" :navigate="fn">
|
||||||
|
<!-- children -->
|
||||||
|
</ActionProvider>
|
||||||
|
|
||||||
|
// type ActionHandler = (params: Record<string, unknown>) => void | Promise<void>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### VisibilityProvider
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<VisibilityProvider>
|
||||||
|
<!-- children -->
|
||||||
|
</VisibilityProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
`VisibilityProvider` reads state from the parent `StateProvider` automatically. Conditions in specs use the `VisibilityCondition` format with `$state` paths (e.g. `{ "$state": "/path" }`, `{ "$state": "/path", "eq": value }`). See [visibility](/docs/visibility) for the full syntax.
|
||||||
|
|
||||||
|
### ValidationProvider
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<ValidationProvider :custom-functions="Record<string, ValidationFunction>">
|
||||||
|
<!-- children -->
|
||||||
|
</ValidationProvider>
|
||||||
|
|
||||||
|
// type ValidationFunction = (value: unknown, args?: object) => boolean | Promise<boolean>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a type-safe component registry from a catalog. Components receive `props`, `children`, `emit`, `on`, and `loading` with catalog-inferred types.
|
||||||
|
|
||||||
|
When the catalog declares actions, the `actions` field is required. When the catalog has no actions (e.g. `actions: {}`), the field is optional. When passing stubs, any `async () => {}` is sufficient.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { h } from "vue";
|
||||||
|
import { defineRegistry } from "@json-render/vue";
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
// Required when catalog declares actions:
|
||||||
|
actions: {
|
||||||
|
submit: async (params) => { /* ... */ },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass to <Renderer>
|
||||||
|
// <Renderer :spec="spec" :registry="registry" />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Renderer
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<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
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Props (via defineRegistry)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { VNode } from "vue";
|
||||||
|
|
||||||
|
interface ComponentContext<P> {
|
||||||
|
props: P; // Typed props from catalog
|
||||||
|
children?: VNode | VNode[]; // Rendered children (for container components)
|
||||||
|
emit: (event: string) => void; // Emit a named event (always defined)
|
||||||
|
on: (event: string) => EventHandle; // Get event handle with metadata
|
||||||
|
loading?: boolean;
|
||||||
|
bindings?: Record<string, string>; // State paths from $bindState/$bindItem expressions
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EventHandle {
|
||||||
|
emit: () => void; // Fire the event
|
||||||
|
shouldPreventDefault: boolean; // Whether any binding requested preventDefault
|
||||||
|
bound: boolean; // Whether any handler is bound
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `emit("press")` for simple event firing. Use `on("click")` when you need metadata like `shouldPreventDefault`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
Link: ({ props, on }) => {
|
||||||
|
const click = on("click");
|
||||||
|
return h("a", {
|
||||||
|
href: props.href,
|
||||||
|
onClick: (e: MouseEvent) => {
|
||||||
|
if (click.shouldPreventDefault) e.preventDefault();
|
||||||
|
click.emit();
|
||||||
|
},
|
||||||
|
}, props.label);
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
### BaseComponentProps
|
||||||
|
|
||||||
|
Catalog-agnostic base type for building reusable component libraries that are not tied to a specific catalog:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { BaseComponentProps } from "@json-render/vue";
|
||||||
|
|
||||||
|
const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) =>
|
||||||
|
h("div", null, [props.title, children]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Composables
|
||||||
|
|
||||||
|
### useStateStore
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
state, // ShallowRef<StateModel> — access with state.value
|
||||||
|
get, // (path: string) => unknown
|
||||||
|
set, // (path: string, value: unknown) => void
|
||||||
|
update, // (updates: Record<string, unknown>) => void
|
||||||
|
} = useStateStore();
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** `state` is a `ShallowRef<StateModel>`, not a plain object. Use `state.value` to read the current state. This differs from the React renderer.
|
||||||
|
|
||||||
|
### useStateValue
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const value = useStateValue(path: string); // ComputedRef<T | undefined>
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns a `ComputedRef` that automatically updates when the state at `path` changes. Use `.value` to access the current value.
|
||||||
|
|
||||||
|
### useStateBinding (deprecated)
|
||||||
|
|
||||||
|
> **Deprecated.** Use `$bindState` expressions with `bindings` prop instead.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const [value, setValue] = useStateBinding(path: string);
|
||||||
|
// value: ComputedRef<T | undefined>
|
||||||
|
// setValue: (value: T) => void
|
||||||
|
```
|
||||||
|
|
||||||
|
### useActions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { execute } = useActions();
|
||||||
|
// execute(binding: ActionBinding) => Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useAction
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { execute, isLoading } = useAction(binding: ActionBinding);
|
||||||
|
// execute: () => Promise<void>
|
||||||
|
// isLoading: ComputedRef<boolean>
|
||||||
|
```
|
||||||
|
|
||||||
|
### useIsVisible
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const isVisible = useIsVisible(condition?: VisibilityCondition);
|
||||||
|
```
|
||||||
|
|
||||||
|
### useFieldValidation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
state, // ComputedRef<FieldValidationState>
|
||||||
|
validate, // () => ValidationResult
|
||||||
|
touch, // () => void
|
||||||
|
clear, // () => void
|
||||||
|
errors, // ComputedRef<string[]>
|
||||||
|
isValid, // ComputedRef<boolean>
|
||||||
|
} = useFieldValidation(path: string, config?: ValidationConfig);
|
||||||
|
```
|
||||||
|
|
||||||
|
`ValidationConfig` is `{ checks?: ValidationCheck[], validateOn?: 'change' | 'blur' | 'submit' }`.
|
||||||
|
|
||||||
|
## Differences from `@json-render/react`
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>API</th>
|
||||||
|
<th>React</th>
|
||||||
|
<th>Vue</th>
|
||||||
|
<th>Note</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>useStateStore().state</code></td>
|
||||||
|
<td><code>StateModel</code> (plain object)</td>
|
||||||
|
<td><code>ShallowRef<StateModel></code></td>
|
||||||
|
<td>Vue reactivity; use <code>state.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useStateValue()</code></td>
|
||||||
|
<td><code>T | undefined</code></td>
|
||||||
|
<td><code>ComputedRef<T | undefined></code></td>
|
||||||
|
<td>Vue reactivity; use <code>.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useStateBinding()</code></td>
|
||||||
|
<td><code>[T | undefined, setter]</code></td>
|
||||||
|
<td><code>[ComputedRef<T | undefined>, setter]</code></td>
|
||||||
|
<td>Vue reactivity; use <code>value.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useAction().isLoading</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td><code>ComputedRef<boolean></code></td>
|
||||||
|
<td>Vue reactivity; use <code>.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useFieldValidation().state</code></td>
|
||||||
|
<td><code>FieldValidationState</code></td>
|
||||||
|
<td><code>ComputedRef<FieldValidationState></code></td>
|
||||||
|
<td>Vue reactivity; use <code>.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useFieldValidation().errors</code></td>
|
||||||
|
<td><code>string[]</code></td>
|
||||||
|
<td><code>ComputedRef<string[]></code></td>
|
||||||
|
<td>Vue reactivity; use <code>.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useFieldValidation().isValid</code></td>
|
||||||
|
<td><code>boolean</code></td>
|
||||||
|
<td><code>ComputedRef<boolean></code></td>
|
||||||
|
<td>Vue reactivity; use <code>.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>VisibilityContextValue.ctx</code></td>
|
||||||
|
<td><code>CoreVisibilityContext</code></td>
|
||||||
|
<td><code>ComputedRef<CoreVisibilityContext></code></td>
|
||||||
|
<td>Vue reactivity; use <code>ctx.value</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>children</code> type</td>
|
||||||
|
<td><code>React.ReactNode</code></td>
|
||||||
|
<td><code>VNode | VNode[]</code></td>
|
||||||
|
<td>Platform-specific</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>useBoundProp</code></td>
|
||||||
|
<td>exported</td>
|
||||||
|
<td>exported</td>
|
||||||
|
<td>Same API; returns <code>[value, setValue]</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>VisibilityProviderProps</code></td>
|
||||||
|
<td>exported</td>
|
||||||
|
<td>not exported (no props)</td>
|
||||||
|
<td>Vue uses slot, no prop needed</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Streaming hooks</td>
|
||||||
|
<td><code>useUIStream</code>, <code>useChatUI</code></td>
|
||||||
|
<td><code>useUIStream</code>, <code>useChatUI</code></td>
|
||||||
|
<td>Same API; returns Vue <code>Ref</code> values</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/xstate")
|
||||||
|
|
||||||
|
# @json-render/xstate
|
||||||
|
|
||||||
|
[XState Store](https://stately.ai/docs/xstate-store) adapter for json-render's `StateStore` interface.
|
||||||
|
|
||||||
|
Requires `@xstate/store` v3+.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/xstate @json-render/core @json-render/react @xstate/store
|
||||||
|
```
|
||||||
|
|
||||||
|
## xstateStoreStateStore
|
||||||
|
|
||||||
|
Create a `StateStore` backed by an `@xstate/store` atom.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Required</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>atom</code></td>
|
||||||
|
<td><code>{'Atom<StateModel>'}</code></td>
|
||||||
|
<td>Yes</td>
|
||||||
|
<td>An <code>@xstate/store</code> atom (from <code>createAtom</code>) holding the json-render state model.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createAtom } from "@xstate/store";
|
||||||
|
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const uiAtom = createAtom({ count: 0 });
|
||||||
|
const store = xstateStoreStateStore({ atom: uiAtom });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through @xstate/store */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td><code>@json-render/core</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/yaml")
|
||||||
|
|
||||||
|
# @json-render/yaml
|
||||||
|
|
||||||
|
YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
|
||||||
|
|
||||||
|
## Prompt Generation
|
||||||
|
|
||||||
|
### yamlPrompt
|
||||||
|
|
||||||
|
Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function yamlPrompt(
|
||||||
|
catalog: Catalog,
|
||||||
|
options?: YamlPromptOptions
|
||||||
|
): string
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { yamlPrompt } from "@json-render/yaml";
|
||||||
|
|
||||||
|
const systemPrompt = yamlPrompt(catalog, {
|
||||||
|
mode: "standalone",
|
||||||
|
customRules: ["Always use dark theme"],
|
||||||
|
editModes: ["merge"],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### YamlPromptOptions
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Default</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>system</code></td>
|
||||||
|
<td><code>string</code></td>
|
||||||
|
<td><code>{'\"You are a UI generator that outputs YAML.\"'}</code></td>
|
||||||
|
<td>Custom system message intro</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>mode</code></td>
|
||||||
|
<td><code>{'\"standalone\" | \"inline\"'}</code></td>
|
||||||
|
<td><code>{'\"standalone\"'}</code></td>
|
||||||
|
<td>Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>customRules</code></td>
|
||||||
|
<td><code>{'string[]'}</code></td>
|
||||||
|
<td><code>{'[]'}</code></td>
|
||||||
|
<td>Additional rules appended to the prompt</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>editModes</code></td>
|
||||||
|
<td><code>{'EditMode[]'}</code></td>
|
||||||
|
<td><code>{'[\"merge\"]'}</code></td>
|
||||||
|
<td>Edit modes to document in the prompt (patch, merge, diff)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## AI SDK Transform
|
||||||
|
|
||||||
|
### createYamlTransform
|
||||||
|
|
||||||
|
Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createYamlTransform(
|
||||||
|
options?: YamlTransformOptions
|
||||||
|
): TransformStream<StreamChunk, StreamChunk>
|
||||||
|
```
|
||||||
|
|
||||||
|
Recognized fence types:
|
||||||
|
|
||||||
|
- <code>{'```yaml-spec'}</code> -- Full YAML spec, parsed progressively
|
||||||
|
- <code>{'```yaml-edit'}</code> -- Partial YAML, deep-merged with current spec
|
||||||
|
- <code>{'```yaml-patch'}</code> -- RFC 6902 JSON Patch lines
|
||||||
|
- <code>{'```diff'}</code> -- Unified diff against serialized spec
|
||||||
|
|
||||||
|
### pipeYamlRender
|
||||||
|
|
||||||
|
Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function pipeYamlRender<T>(
|
||||||
|
stream: ReadableStream<T>,
|
||||||
|
options?: YamlTransformOptions
|
||||||
|
): ReadableStream<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { pipeYamlRender } from "@json-render/yaml";
|
||||||
|
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||||
|
|
||||||
|
const stream = createUIMessageStream({
|
||||||
|
execute: async ({ writer }) => {
|
||||||
|
writer.merge(pipeYamlRender(result.toUIMessageStream()));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return createUIMessageStreamResponse({ stream });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Streaming Parser
|
||||||
|
|
||||||
|
### createYamlStreamCompiler
|
||||||
|
|
||||||
|
Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createYamlStreamCompiler<T>(
|
||||||
|
initial?: Partial<T>
|
||||||
|
): YamlStreamCompiler<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createYamlStreamCompiler } from "@json-render/yaml";
|
||||||
|
|
||||||
|
const compiler = createYamlStreamCompiler<Spec>();
|
||||||
|
|
||||||
|
compiler.push("root: main\n");
|
||||||
|
compiler.push("elements:\n main:\n type: Card\n");
|
||||||
|
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
```
|
||||||
|
|
||||||
|
### YamlStreamCompiler
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Method</th>
|
||||||
|
<th>Returns</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>push(chunk)</code></td>
|
||||||
|
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
||||||
|
<td>Push a chunk of text, returns current result and new patches</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>flush()</code></td>
|
||||||
|
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
|
||||||
|
<td>Flush remaining buffer, return final result</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>getResult()</code></td>
|
||||||
|
<td><code>T</code></td>
|
||||||
|
<td>Get the current compiled result</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>getPatches()</code></td>
|
||||||
|
<td><code>{'JsonPatch[]'}</code></td>
|
||||||
|
<td>Get all patches applied so far</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>reset(initial?)</code></td>
|
||||||
|
<td><code>void</code></td>
|
||||||
|
<td>Reset to initial state</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Fence Constants
|
||||||
|
|
||||||
|
Exported string constants for fence detection in custom parsers:
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Constant</th>
|
||||||
|
<th>Value</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_SPEC_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-spec\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_EDIT_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-edit\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>YAML_PATCH_FENCE</code></td>
|
||||||
|
<td><code>{'\"```yaml-patch\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>DIFF_FENCE</code></td>
|
||||||
|
<td><code>{'\"```diff\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>FENCE_CLOSE</code></td>
|
||||||
|
<td><code>{'\"```\"'}</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Re-exports from @json-render/core
|
||||||
|
|
||||||
|
### diffToPatches
|
||||||
|
|
||||||
|
Generate RFC 6902 JSON Patch operations that transform one object into another.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function diffToPatches(
|
||||||
|
oldObj: Record<string, unknown>,
|
||||||
|
newObj: Record<string, unknown>,
|
||||||
|
basePath?: string
|
||||||
|
): JsonPatch[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### deepMergeSpec
|
||||||
|
|
||||||
|
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function deepMergeSpec(
|
||||||
|
base: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>
|
||||||
|
): Record<string, unknown>
|
||||||
|
```
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/zustand")
|
||||||
|
|
||||||
|
# @json-render/zustand
|
||||||
|
|
||||||
|
Zustand adapter for json-render's `StateStore` interface.
|
||||||
|
|
||||||
|
Requires Zustand v5+. Zustand v4 is not supported due to breaking API changes in the vanilla store interface.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/zustand @json-render/core @json-render/react zustand
|
||||||
|
```
|
||||||
|
|
||||||
|
## zustandStateStore
|
||||||
|
|
||||||
|
Create a `StateStore` backed by a Zustand vanilla store.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { zustandStateStore } from "@json-render/zustand";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Required</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>store</code></td>
|
||||||
|
<td><code>{'StoreApi<S>'}</code></td>
|
||||||
|
<td>Yes</td>
|
||||||
|
<td>A Zustand vanilla store (from <code>createStore</code> in <code>zustand/vanilla</code>).</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>selector</code></td>
|
||||||
|
<td><code>{'(state: S) => StateModel'}</code></td>
|
||||||
|
<td>No</td>
|
||||||
|
<td>Select the json-render slice from the store state. Defaults to the entire state.</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>updater</code></td>
|
||||||
|
<td><code>{'(nextState: StateModel, store: StoreApi<S>) => void'}</code></td>
|
||||||
|
<td>No</td>
|
||||||
|
<td>Apply a state change back to the store. Defaults to a shallow merge.</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
### Example
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStore } from "zustand/vanilla";
|
||||||
|
import { zustandStateStore } from "@json-render/zustand";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const bearStore = createStore(() => ({
|
||||||
|
count: 0,
|
||||||
|
name: "Bear",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = zustandStateStore({ store: bearStore });
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through Zustand */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nested Slice
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const appStore = createStore(() => ({
|
||||||
|
ui: { count: 0 },
|
||||||
|
auth: { token: null },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const store = zustandStateStore({
|
||||||
|
store: appStore,
|
||||||
|
selector: (s) => s.ui,
|
||||||
|
updater: (next, s) => s.setState({ ui: next }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>StateStore</code></td>
|
||||||
|
<td><code>@json-render/core</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Catalog" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/catalog")
|
||||||
|
|
||||||
# Catalog
|
# Catalog
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ A catalog is the vocabulary for your UI. While the [schema](/docs/schemas) defin
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
const catalog = defineCatalog(schema, {
|
||||||
|
|||||||
@@ -1,9 +1,187 @@
|
|||||||
export const metadata = { title: "Changelog" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/changelog")
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
Notable changes and updates to json-render.
|
Notable changes and updates to json-render.
|
||||||
|
|
||||||
|
## v0.10.0
|
||||||
|
|
||||||
|
February 2026
|
||||||
|
|
||||||
|
### New: `@json-render/vue`
|
||||||
|
|
||||||
|
Vue 3 renderer for json-render with full feature parity with `@json-render/react`. Data binding, visibility conditions, actions, validation, repeat scopes, streaming, and external store support.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/core @json-render/vue
|
||||||
|
```
|
||||||
|
|
||||||
|
```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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Providers: `StateProvider`, `ActionProvider`, `VisibilityProvider`, `ValidationProvider`. Composables: `useStateStore`, `useStateValue`, `useActions`, `useAction`, `useIsVisible`, `useFieldValidation`, `useBoundProp`, `useUIStream`, `useChatUI`.
|
||||||
|
|
||||||
|
See the [Vue API reference](/docs/api/vue) for details.
|
||||||
|
|
||||||
|
### New: `@json-render/xstate`
|
||||||
|
|
||||||
|
[XState Store](https://stately.ai/docs/xstate-store) (atom) adapter for json-render's `StateStore` interface. Wire an `@xstate/store` atom as the state backend for any renderer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/xstate @xstate/store
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createAtom } from "@xstate/store";
|
||||||
|
import { xstateStoreStateStore } from "@json-render/xstate";
|
||||||
|
|
||||||
|
const atom = createAtom({ count: 0 });
|
||||||
|
const store = xstateStoreStateStore({ atom });
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `@xstate/store` v3+.
|
||||||
|
|
||||||
|
### New: `$computed` and `$template` Expressions
|
||||||
|
|
||||||
|
Two new prop expression types for dynamic values:
|
||||||
|
|
||||||
|
- **`$template`** -- interpolate state values into strings: `{ "$template": "Hello, ${/user/name}!" }`
|
||||||
|
- **`$computed`** -- call registered functions: `{ "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" } } }`
|
||||||
|
|
||||||
|
Register functions via the `functions` prop on `JSONUIProvider` or `createRenderer`. See [Computed Values](/docs/computed-values) for details.
|
||||||
|
|
||||||
|
### New: State Watchers
|
||||||
|
|
||||||
|
Elements can declare a `watch` field to trigger actions when state values change. Useful for cascading dependencies like country/city selects.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Select",
|
||||||
|
"props": { "value": { "$bindState": "/form/country" }, "options": ["US", "Canada"] },
|
||||||
|
"watch": {
|
||||||
|
"/form/country": { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`watch` is a top-level field on elements (sibling of type/props/children), not inside props. Watchers only fire on value changes, not on initial render. See [Watchers](/docs/watchers) for details.
|
||||||
|
|
||||||
|
### New: Cross-Field Validation
|
||||||
|
|
||||||
|
New built-in validation functions for cross-field comparisons:
|
||||||
|
|
||||||
|
- `equalTo` -- alias for `matches` with clearer semantics
|
||||||
|
- `lessThan` -- value must be less than another field
|
||||||
|
- `greaterThan` -- value must be greater than another field
|
||||||
|
- `requiredIf` -- required only when a condition field is truthy
|
||||||
|
|
||||||
|
Validation check args now resolve through `resolvePropValue`, so `$state` expressions work consistently.
|
||||||
|
|
||||||
|
### New: `validateForm` Action
|
||||||
|
|
||||||
|
Built-in action (React) that validates all registered form fields at once and writes `{ valid, errors }` to state:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"on": {
|
||||||
|
"press": [
|
||||||
|
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||||
|
{ "action": "submitForm" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Improved: shadcn/ui Validation
|
||||||
|
|
||||||
|
All form components now support `checks` and `validateOn` props:
|
||||||
|
- Checkbox, Radio, Switch added validation support
|
||||||
|
- `validateOn` controls timing: `"change"` (default for Select, Checkbox, Radio, Switch), `"blur"` (default for Input, Textarea), or `"submit"`
|
||||||
|
|
||||||
|
### New Examples
|
||||||
|
|
||||||
|
- **Vue example** -- standalone Vue 3 app with custom components
|
||||||
|
- **Vite Renderers** -- side-by-side React and Vue renderers with shared catalog
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.9.1
|
||||||
|
|
||||||
|
February 2026
|
||||||
|
|
||||||
|
### Fixed: Install failure due to private dependency
|
||||||
|
|
||||||
|
`@json-render/react`, `@json-render/react-pdf`, and `@json-render/react-native` v0.9.0 failed to install because `@internal/react-state` (a private workspace package) was published as a dependency. The internal package is now bundled into each renderer at build time, so it no longer needs to be resolved from npm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v0.9.0
|
||||||
|
|
||||||
|
February 2026
|
||||||
|
|
||||||
|
### New: External State Store
|
||||||
|
|
||||||
|
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
|
||||||
|
|
||||||
|
- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
|
||||||
|
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop
|
||||||
|
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
|
||||||
|
- When `store` is omitted, everything works exactly as before (fully backward compatible)
|
||||||
|
- Applied across all platform packages: react, react-native, react-pdf
|
||||||
|
- Store utilities (`createStoreAdapter`, `immutableSetByPath`, `flattenToPointers`) available via `@json-render/core/store-utils` for building custom adapters
|
||||||
|
|
||||||
|
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
|
||||||
|
|
||||||
|
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||||
|
|
||||||
|
### Changed: `onStateChange` signature updated (breaking)
|
||||||
|
|
||||||
|
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before
|
||||||
|
onStateChange?: (path: string, value: unknown) => void
|
||||||
|
|
||||||
|
// After
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
|
||||||
|
```
|
||||||
|
|
||||||
|
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
|
||||||
|
|
||||||
|
### Fixed: Server-safe schema import
|
||||||
|
|
||||||
|
`@json-render/react` barrel-imports React contexts that call `createContext`, which crashes in Next.js App Router API routes (RSC runtime strips `createContext`). All docs, examples, and skills now import `schema` from `@json-render/react/schema` instead of `@json-render/react`.
|
||||||
|
|
||||||
|
For combined imports, split into separate `schema` (subpath) and client API (main entry) lines:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { schema } from "@json-render/react/schema";
|
||||||
|
import { defineRegistry, Renderer } from "@json-render/react";
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fixed: Chaining actions
|
||||||
|
|
||||||
|
Fixed an issue where chaining multiple actions on the same event (e.g. `setState` followed by a custom action) did not execute all actions. Affected `@json-render/react`, `@json-render/react-native`, and `@json-render/react-pdf`.
|
||||||
|
|
||||||
|
### Fixed: Zod array inner type resolution
|
||||||
|
|
||||||
|
Fixed safely resolving the inner type for Zod arrays in schema introspection, preventing errors when catalog component props use `z.array()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v0.8.0
|
## v0.8.0
|
||||||
|
|
||||||
February 2026
|
February 2026
|
||||||
@@ -163,6 +341,8 @@ February 2026
|
|||||||
|
|
||||||
### New: Chat Mode (Inline GenUI)
|
### New: Chat Mode (Inline GenUI)
|
||||||
|
|
||||||
|
> **Note:** These modes were renamed in v0.12.1 — "Generate" is now "Standalone" and "Chat" is now "Inline". The old names are accepted as deprecated aliases.
|
||||||
|
|
||||||
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
|
json-render now supports two generation modes: **Generate** (JSONL-only, the default) and **Chat** (text + JSONL inline). Chat mode lets the AI respond conversationally with embedded UI specs, ideal for chatbots and copilot experiences.
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Code Export" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/code-export")
|
||||||
|
|
||||||
# Code Export
|
# Code Export
|
||||||
|
|
||||||
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
|
|||||||
```bash
|
```bash
|
||||||
cd examples/dashboard
|
cd examples/dashboard
|
||||||
pnpm dev
|
pnpm dev
|
||||||
# Open http://localhost:3001
|
# Open http://dashboard-demo.json-render.localhost:1355
|
||||||
# Generate a widget, then click "Export Project"
|
# Generate a widget, then click "Export Project"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/computed-values")
|
||||||
|
|
||||||
|
# Computed Values
|
||||||
|
|
||||||
|
Derive dynamic prop values using registered functions or string templates.
|
||||||
|
|
||||||
|
## `$template` — String Interpolation
|
||||||
|
|
||||||
|
Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Text",
|
||||||
|
"props": {
|
||||||
|
"text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
|
||||||
|
|
||||||
|
Missing paths resolve to an empty string.
|
||||||
|
|
||||||
|
## `$computed` — Registered Functions
|
||||||
|
|
||||||
|
Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Text",
|
||||||
|
"props": {
|
||||||
|
"text": {
|
||||||
|
"$computed": "fullName",
|
||||||
|
"args": {
|
||||||
|
"first": { "$state": "/form/firstName" },
|
||||||
|
"last": { "$state": "/form/lastName" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registering Functions
|
||||||
|
|
||||||
|
Functions are registered in the catalog and provided at runtime.
|
||||||
|
|
||||||
|
**Catalog definition (for AI prompt generation):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema } from '@json-render/react/schema';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: { /* ... */ },
|
||||||
|
functions: {
|
||||||
|
fullName: {
|
||||||
|
description: 'Combines first and last name into a full name',
|
||||||
|
},
|
||||||
|
formatCurrency: {
|
||||||
|
description: 'Formats a number as currency',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Runtime implementation:**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { JSONUIProvider } from '@json-render/react';
|
||||||
|
|
||||||
|
const functions = {
|
||||||
|
fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
|
||||||
|
formatCurrency: (args) => {
|
||||||
|
const value = Number(args.value ?? 0);
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: (args.currency as string) ?? 'USD',
|
||||||
|
}).format(value);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
<JSONUIProvider registry={registry} functions={functions}>
|
||||||
|
<Renderer spec={spec} registry={registry} />
|
||||||
|
</JSONUIProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using with `createRenderer`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const MyRenderer = createRenderer(catalog, components);
|
||||||
|
|
||||||
|
<MyRenderer
|
||||||
|
spec={spec}
|
||||||
|
functions={functions}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Combining Expressions
|
||||||
|
|
||||||
|
`$computed` args can use any expression type. This example computes a total from repeat item fields:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$computed": "lineTotal",
|
||||||
|
"args": {
|
||||||
|
"price": { "$item": "price" },
|
||||||
|
"quantity": { "$item": "quantity" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Watchers](/docs/watchers) — react to state changes with cascading actions
|
||||||
|
- [Data Binding](/docs/data-binding) — all expression types
|
||||||
|
- [Validation](/docs/validation) — validate form inputs
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Custom Schema & Renderer" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/custom-schema")
|
||||||
|
|
||||||
# Custom Schema & Renderer
|
# Custom Schema & Renderer
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Data Binding" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/data-binding")
|
||||||
|
|
||||||
# Data Binding
|
# Data Binding
|
||||||
|
|
||||||
@@ -212,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
|
|||||||
|
|
||||||
The condition uses the same [visibility](/docs/visibility) expression format.
|
The condition uses the same [visibility](/docs/visibility) expression format.
|
||||||
|
|
||||||
|
## Template Strings
|
||||||
|
|
||||||
|
Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Text",
|
||||||
|
"props": {
|
||||||
|
"text": { "$template": "Welcome back, ${/user/name}!" }
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
|
||||||
|
|
||||||
## Quick Reference
|
## Quick Reference
|
||||||
|
|
||||||
<div className="my-6 overflow-x-auto">
|
<div className="my-6 overflow-x-auto">
|
||||||
@@ -254,10 +271,39 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
|||||||
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
<td><code>{'{ "$bindItem": "field" }'}</code></td>
|
||||||
<td>Form components inside repeat</td>
|
<td>Form components inside repeat</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>{"$template"}</code></td>
|
||||||
|
<td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
|
||||||
|
<td>Anywhere (string props)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>{"$computed"}</code></td>
|
||||||
|
<td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
|
||||||
|
<td>Anywhere (requires registered function)</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react";
|
||||||
|
|
||||||
|
const store = createStateStore({ user: { name: "Alice" } });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — React re-renders automatically:
|
||||||
|
store.set("/user/name", "Bob");
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
export const metadata = { title: "Generation Modes" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/generation-modes")
|
||||||
|
|
||||||
# Generation Modes
|
# Generation Modes
|
||||||
|
|
||||||
json-render supports two modes for AI-generated UI: **Generate mode** for standalone UI and **Chat mode** for inline UI within a conversation.
|
json-render supports two modes for AI-generated UI: **Standalone mode** for standalone UI and **Inline mode** for inline UI within a conversation.
|
||||||
|
|
||||||
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
|
The mode controls how the AI formats its output and how your app processes the stream. The underlying JSONL patch format is the same in both modes.
|
||||||
|
|
||||||
<GenerationModesDiagram />
|
<GenerationModesDiagram />
|
||||||
|
|
||||||
## Generate Mode (Standalone)
|
## Standalone Mode
|
||||||
|
|
||||||
In generate mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
|
In standalone mode, the AI outputs **only JSONL patches** — no prose, no markdown. The entire response is a UI spec.
|
||||||
|
|
||||||
This is the default mode and is ideal for:
|
This is the default mode and is ideal for:
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ This is the default mode and is ideal for:
|
|||||||
```typescript
|
```typescript
|
||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
|
|
||||||
// Generate mode is the default (no mode option needed)
|
// Standalone mode is the default (no mode option needed)
|
||||||
const systemPrompt = catalog.prompt({
|
const systemPrompt = catalog.prompt({
|
||||||
customRules: [
|
customRules: [
|
||||||
"Use Card as root for forms and small UIs.",
|
"Use Card as root for forms and small UIs.",
|
||||||
@@ -73,9 +74,9 @@ The AI outputs only JSONL — one patch per line, no surrounding text:
|
|||||||
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
|
{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Sign In"}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Chat Mode (Inline)
|
## Inline Mode
|
||||||
|
|
||||||
In chat mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
|
In inline mode, the AI responds **conversationally first**, then outputs JSONL patches on their own lines. Text-only replies are allowed when no UI is needed (e.g. greetings, clarifying questions).
|
||||||
|
|
||||||
This is ideal for:
|
This is ideal for:
|
||||||
|
|
||||||
@@ -91,8 +92,8 @@ import { streamText } from "ai";
|
|||||||
import { pipeJsonRender } from "@json-render/core";
|
import { pipeJsonRender } from "@json-render/core";
|
||||||
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
|
||||||
|
|
||||||
// Enable chat mode
|
// Enable inline mode
|
||||||
const systemPrompt = catalog.prompt({ mode: "chat" });
|
const systemPrompt = catalog.prompt({ mode: "inline" });
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: yourModel,
|
model: yourModel,
|
||||||
@@ -179,8 +180,8 @@ If the user asks a simple question ("what does BTC stand for?"), the AI replies
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th />
|
<th />
|
||||||
<th>Generate</th>
|
<th>Standalone</th>
|
||||||
<th>Chat</th>
|
<th>Inline</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -197,7 +198,7 @@ If the user asks a simple question ("what does BTC stand for?"), the AI replies
|
|||||||
<tr>
|
<tr>
|
||||||
<td>System prompt</td>
|
<td>System prompt</td>
|
||||||
<td><code>{"catalog.prompt()"}</code></td>
|
<td><code>{"catalog.prompt()"}</code></td>
|
||||||
<td><code>{'catalog.prompt({ mode: "chat" })'}</code></td>
|
<td><code>{'catalog.prompt({ mode: "inline" })'}</code></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Stream utility</td>
|
<td>Stream utility</td>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Installation" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/installation")
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
@@ -8,6 +9,26 @@ Install the core package plus your renderer of choice.
|
|||||||
|
|
||||||
<PackageInstall packages="@json-render/core @json-render/react" />
|
<PackageInstall packages="@json-render/core @json-render/react" />
|
||||||
|
|
||||||
|
Peer dependencies: `react ^19.0.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
<PackageInstall packages="react zod" />
|
||||||
|
|
||||||
|
## For Vue
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/vue" />
|
||||||
|
|
||||||
|
Peer dependencies: `vue ^3.5.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
<PackageInstall packages="vue zod" />
|
||||||
|
|
||||||
|
## For Svelte
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/core @json-render/svelte" />
|
||||||
|
|
||||||
|
Peer dependencies: `svelte ^5.0.0` and `zod ^4.0.0`.
|
||||||
|
|
||||||
|
<PackageInstall packages="svelte zod" />
|
||||||
|
|
||||||
## For React UI with shadcn/ui
|
## For React UI with shadcn/ui
|
||||||
|
|
||||||
Pre-built components for fast prototyping and production use:
|
Pre-built components for fast prototyping and production use:
|
||||||
@@ -24,14 +45,23 @@ Requires Tailwind CSS in your project. See the [@json-render/shadcn API referenc
|
|||||||
|
|
||||||
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
|
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
|
||||||
|
|
||||||
## Peer Dependencies
|
## For React Email
|
||||||
|
|
||||||
json-render requires the following peer dependencies:
|
<PackageInstall packages="@json-render/core @json-render/react-email @react-email/components @react-email/render" />
|
||||||
|
|
||||||
- `react` ^19.0.0
|
## For External State Management (Optional)
|
||||||
- `zod` ^4.0.0
|
|
||||||
|
|
||||||
<PackageInstall packages="react zod" />
|
If you want to wire json-render to an existing state management library instead of the built-in store, install the adapter for your library:
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/zustand" />
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/redux" />
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/jotai" />
|
||||||
|
|
||||||
|
<PackageInstall packages="@json-render/xstate" />
|
||||||
|
|
||||||
|
See the [Data Binding](/docs/data-binding#external-store-controlled-mode) guide for usage.
|
||||||
|
|
||||||
## For AI Integration
|
## For AI Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Migration Guide" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/migration")
|
||||||
|
|
||||||
# Migration Guide
|
# Migration Guide
|
||||||
|
|
||||||
@@ -30,15 +31,23 @@ import { StateProvider } from "@json-render/react";
|
|||||||
|
|
||||||
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
|
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
|
||||||
|
|
||||||
| Before | After |
|
<table>
|
||||||
|--------|-------|
|
<thead>
|
||||||
| `DataProvider` | `StateProvider` |
|
<tr>
|
||||||
| `data` prop | `initialState` prop |
|
<th>Before</th>
|
||||||
| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
|
<th>After</th>
|
||||||
| `useData` | `useStateStore` |
|
</tr>
|
||||||
| `useDataValue` | `useStateValue` |
|
</thead>
|
||||||
| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
|
<tbody>
|
||||||
| `DataModel` type | `StateModel` type |
|
<tr><td><code>DataProvider</code></td><td><code>StateProvider</code></td></tr>
|
||||||
|
<tr><td><code>data</code> prop</td><td><code>initialState</code> prop</td></tr>
|
||||||
|
<tr><td><code>getValue</code> / <code>setValue</code> props</td><td>Removed (use <code>useStateStore()</code> hook for <code>get</code> / <code>set</code>)</td></tr>
|
||||||
|
<tr><td><code>useData</code></td><td><code>useStateStore</code></td></tr>
|
||||||
|
<tr><td><code>useDataValue</code></td><td><code>useStateValue</code></td></tr>
|
||||||
|
<tr><td><code>useDataBinding</code></td><td><code>useStateBinding</code> (deprecated, use <code>useBoundProp</code> instead)</td></tr>
|
||||||
|
<tr><td><code>DataModel</code> type</td><td><code>StateModel</code> type</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Dynamic Expressions
|
## Dynamic Expressions
|
||||||
|
|
||||||
@@ -80,10 +89,18 @@ Inside repeat scopes, use `$item` and `$index`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Before | After |
|
<table>
|
||||||
|--------|-------|
|
<thead>
|
||||||
| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
|
<tr>
|
||||||
| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
|
<th>Before</th>
|
||||||
|
<th>After</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>{'{ "$path": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
|
||||||
|
<tr><td><code>{'{ "$data": "/..." }'}</code></td><td><code>{'{ "$state": "/..." }'}</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Two-Way Binding
|
## Two-Way Binding
|
||||||
|
|
||||||
@@ -292,10 +309,35 @@ const catalog = defineCatalog(schema, {
|
|||||||
|
|
||||||
const prompt = catalog.prompt();
|
const prompt = catalog.prompt();
|
||||||
|
|
||||||
// Chat mode prompt
|
// Inline mode prompt (formerly "chat")
|
||||||
|
const inlinePrompt = catalog.prompt({ mode: "inline" });
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generation Modes
|
||||||
|
|
||||||
|
The generation mode values passed to `catalog.prompt()` have been renamed for clarity:
|
||||||
|
|
||||||
|
- `"generate"` is now `"standalone"`
|
||||||
|
- `"chat"` is now `"inline"`
|
||||||
|
|
||||||
|
The old names are accepted as deprecated aliases, so existing code will continue to work. Update when convenient.
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const prompt = catalog.prompt({ mode: "generate" });
|
||||||
const chatPrompt = catalog.prompt({ mode: "chat" });
|
const chatPrompt = catalog.prompt({ mode: "chat" });
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const prompt = catalog.prompt({ mode: "standalone" });
|
||||||
|
const inlinePrompt = catalog.prompt({ mode: "inline" });
|
||||||
|
```
|
||||||
|
|
||||||
|
The default mode (when no `mode` option is provided) is `"standalone"`, which behaves identically to the previous `"generate"` default.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
|
`ValidationCheck` now uses `type` instead of `fn`, `ValidationProvider` uses `customFunctions` instead of `functions`, and `useFieldValidation` takes a config object instead of a checks array.
|
||||||
@@ -314,11 +356,19 @@ const chatPrompt = catalog.prompt({ mode: "chat" });
|
|||||||
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
|
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
|
||||||
```
|
```
|
||||||
|
|
||||||
| Before | After |
|
<table>
|
||||||
|--------|-------|
|
<thead>
|
||||||
| `{ fn: "required" }` | `{ type: "required" }` |
|
<tr>
|
||||||
| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
|
<th>Before</th>
|
||||||
| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
|
<th>After</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>{'{ fn: "required" }'}</code></td><td><code>{'{ type: "required" }'}</code></td></tr>
|
||||||
|
<tr><td><code>{'ValidationProvider functions={...}'}</code></td><td><code>{'ValidationProvider customFunctions={...}'}</code></td></tr>
|
||||||
|
<tr><td><code>useFieldValidation(path, checks)</code></td><td><code>useFieldValidation(path, config)</code> where config is <code>{'{ checks, validateOn? }'}</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
## Visibility Provider
|
## Visibility Provider
|
||||||
|
|
||||||
@@ -397,16 +447,57 @@ Action params in specs now use `statePath` instead of `path`.
|
|||||||
|
|
||||||
The following exports have been removed from `@json-render/core`:
|
The following exports have been removed from `@json-render/core`:
|
||||||
|
|
||||||
| Removed | Replacement |
|
<table>
|
||||||
|---------|-------------|
|
<thead>
|
||||||
| `createCatalog` | `defineCatalog(schema, config)` |
|
<tr>
|
||||||
| `generateCatalogPrompt` | `catalog.prompt()` |
|
<th>Removed</th>
|
||||||
| `generateSystemPrompt` | `catalog.prompt()` |
|
<th>Replacement</th>
|
||||||
| `ComponentDefinition` | Use catalog component config directly |
|
</tr>
|
||||||
| `CatalogConfig` | Use `defineCatalog` parameters |
|
</thead>
|
||||||
| `SystemPromptOptions` | Use `PromptOptions` |
|
<tbody>
|
||||||
| `LogicExpression` | Use `VisibilityCondition` |
|
<tr>
|
||||||
| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
|
<td><code>createCatalog</code></td>
|
||||||
| `evaluateLogicExpression` | Use `evaluateVisibility` |
|
<td><code>defineCatalog(schema, config)</code></td>
|
||||||
| `createRendererFromCatalog` | Use `defineRegistry` |
|
</tr>
|
||||||
| `traverseTree` (codegen) | Use `traverseSpec` |
|
<tr>
|
||||||
|
<td><code>generateCatalogPrompt</code></td>
|
||||||
|
<td><code>catalog.prompt()</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>generateSystemPrompt</code></td>
|
||||||
|
<td><code>catalog.prompt()</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentDefinition</code></td>
|
||||||
|
<td>Use catalog component config directly</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>CatalogConfig</code></td>
|
||||||
|
<td>Use <code>defineCatalog</code> parameters</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>SystemPromptOptions</code></td>
|
||||||
|
<td>Use <code>PromptOptions</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>LogicExpression</code></td>
|
||||||
|
<td>Use <code>VisibilityCondition</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>AuthState</code></td>
|
||||||
|
<td>Model auth as regular state (e.g. <code>/auth/isSignedIn</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>evaluateLogicExpression</code></td>
|
||||||
|
<td>Use <code>evaluateVisibility</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>createRendererFromCatalog</code></td>
|
||||||
|
<td>Use <code>defineRegistry</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>traverseTree</code> (codegen)</td>
|
||||||
|
<td>Use <code>traverseSpec</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "OpenAPI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/openapi")
|
||||||
|
|
||||||
# OpenAPI Integration
|
# OpenAPI Integration
|
||||||
|
|
||||||
@@ -99,7 +100,7 @@ Create components that map to OpenAPI data types:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const openapiCatalog = defineCatalog(schema, {
|
export const openapiCatalog = defineCatalog(schema, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Introduction" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs")
|
||||||
|
|
||||||
# Introduction
|
# Introduction
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ A catalog declares what AI can use: components with typed props, actions with ty
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const catalog = defineCatalog(schema, {
|
export const catalog = defineCatalog(schema, {
|
||||||
@@ -90,7 +91,7 @@ The result is a native UI built from your own components — not an iframe, not
|
|||||||
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
|
- **[Streaming](/docs/streaming)** — Render progressively as the AI responds. Each JSONL patch adds to the spec and the UI updates in real time.
|
||||||
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
|
- **[Data Binding](/docs/data-binding)** — Bind props to runtime data with `$state` paths, repeat elements over arrays, and wire two-way input bindings.
|
||||||
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
|
- **[Visibility](/docs/visibility)** — Show or hide elements based on state conditions. The AI can generate conditional UIs without writing logic.
|
||||||
- **[Generation Modes](/docs/generation-modes)** — Generate standalone UI (playground/builder) or inline UI within a chat conversation.
|
- **[Generation Modes](/docs/generation-modes)** — Standalone mode for full-page generated UI or inline mode for UI embedded in a conversation.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Quick Start" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/quick-start")
|
||||||
|
|
||||||
# Quick Start
|
# Quick Start
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@ Create a catalog that defines what components AI can use:
|
|||||||
```typescript
|
```typescript
|
||||||
// lib/catalog.ts
|
// lib/catalog.ts
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const catalog = defineCatalog(schema, {
|
export const catalog = defineCatalog(schema, {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Registry" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/registry")
|
||||||
|
|
||||||
# Registry
|
# Registry
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ What a registry contains depends on the schema you use. Each package defines its
|
|||||||
|
|
||||||
- **`@json-render/react`** — Components (React elements) and action handlers
|
- **`@json-render/react`** — Components (React elements) and action handlers
|
||||||
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
|
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
|
||||||
|
- **`@json-render/react-email`** — Email components (React Email / HTML)
|
||||||
- **`@json-render/remotion`** — Clip components, transitions, and effects
|
- **`@json-render/remotion`** — Clip components, transitions, and effects
|
||||||
|
|
||||||
## @json-render/react
|
## @json-render/react
|
||||||
@@ -132,7 +134,7 @@ Actions are declared in your [catalog](/docs/catalog). The `@json-render/react`
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react';
|
import { schema } from '@json-render/react/schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
const catalog = defineCatalog(schema, {
|
||||||
@@ -277,6 +279,31 @@ export const { registry } = defineRegistry(catalog, {
|
|||||||
|
|
||||||
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
|
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
|
||||||
|
|
||||||
|
## @json-render/react-email
|
||||||
|
|
||||||
|
`@json-render/react-email` uses `defineRegistry` like React and React Native. Components render to React Email primitives (`@react-email/components`). Use `renderToHtml` or `renderToPlainText` for server-side email output:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry } from '@json-render/react-email';
|
||||||
|
import { renderToHtml } from '@json-render/react-email';
|
||||||
|
import { Body, Container, Heading, Text } from '@react-email/components';
|
||||||
|
|
||||||
|
export const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) => (
|
||||||
|
<Container style={{ padding: 16, backgroundColor: '#fff' }}>
|
||||||
|
<Heading>{props.title}</Heading>
|
||||||
|
{children}
|
||||||
|
</Container>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const html = await renderToHtml(spec, { registry });
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/react-email API reference](/docs/api/react-email) for the full API.
|
||||||
|
|
||||||
## @json-render/remotion
|
## @json-render/remotion
|
||||||
|
|
||||||
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata";
|
||||||
|
export const metadata = pageMetadata("docs/renderers");
|
||||||
|
|
||||||
|
# Renderers
|
||||||
|
|
||||||
|
json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format.
|
||||||
|
|
||||||
|
All renderers share the same workflow:
|
||||||
|
|
||||||
|
1. Define a catalog with `defineCatalog`
|
||||||
|
2. AI generates a JSON spec
|
||||||
|
3. The renderer turns the spec into platform-native output
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Renderer</th>
|
||||||
|
<th>Package</th>
|
||||||
|
<th>Output</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>React</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/react</code>
|
||||||
|
</td>
|
||||||
|
<td>React component tree</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Vue</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/vue</code>
|
||||||
|
</td>
|
||||||
|
<td>Vue 3 component tree</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Svelte</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/svelte</code>
|
||||||
|
</td>
|
||||||
|
<td>Svelte 5 component tree</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Solid</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/solid</code>
|
||||||
|
</td>
|
||||||
|
<td>SolidJS component tree</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>shadcn/ui</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/shadcn</code>
|
||||||
|
</td>
|
||||||
|
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>React Native</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/react-native</code>
|
||||||
|
</td>
|
||||||
|
<td>Native mobile views</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Image</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/image</code>
|
||||||
|
</td>
|
||||||
|
<td>SVG / PNG (via Satori)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>React PDF</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/react-pdf</code>
|
||||||
|
</td>
|
||||||
|
<td>PDF documents</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Remotion</td>
|
||||||
|
<td>
|
||||||
|
<code>@json-render/remotion</code>
|
||||||
|
</td>
|
||||||
|
<td>Video compositions</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## React
|
||||||
|
|
||||||
|
Render specs as React component trees in the browser. Supports data binding, streaming, actions, validation, visibility, and computed values.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry, Renderer } from "@json-render/react";
|
||||||
|
import { schema } from "@json-render/react/schema";
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, { components });
|
||||||
|
<Renderer spec={spec} registry={registry} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
|
||||||
|
|
||||||
|
## Vue
|
||||||
|
|
||||||
|
Vue 3 renderer with full feature parity with React: data binding, visibility, actions, validation, repeat scopes, and streaming.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
|
||||||
|
|
||||||
|
## Svelte
|
||||||
|
|
||||||
|
Svelte 5 renderer with runes-compatible context helpers, visibility conditions, actions, and streaming support.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineRegistry, Renderer } from "@json-render/svelte";
|
||||||
|
import { schema } from "@json-render/svelte/schema";
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: ({ props, children }) => /* Svelte snippet */,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/svelte API reference](/docs/api/svelte) for details.
|
||||||
|
|
||||||
|
## Solid
|
||||||
|
|
||||||
|
SolidJS renderer with fine-grained reactivity, state bindings, validation, visibility, and event-driven actions.
|
||||||
|
|
||||||
|
```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>,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
<Renderer spec={spec} registry={registry} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/solid API reference](/docs/api/solid) for details.
|
||||||
|
|
||||||
|
## shadcn/ui
|
||||||
|
|
||||||
|
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
|
||||||
|
|
||||||
|
```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";
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: {
|
||||||
|
Card: shadcnComponentDefinitions.Card,
|
||||||
|
Button: shadcnComponentDefinitions.Button,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Card: shadcnComponents.Card,
|
||||||
|
Button: shadcnComponents.Button,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
|
||||||
|
|
||||||
|
## React Native
|
||||||
|
|
||||||
|
Render specs as native mobile views. Includes 25+ standard components and standard action definitions.
|
||||||
|
|
||||||
|
```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";
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: { ...standardComponentDefinitions },
|
||||||
|
actions: standardActionDefinitions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, { components: {} });
|
||||||
|
<Renderer spec={spec} registry={registry} />;
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
|
||||||
|
|
||||||
|
## Image
|
||||||
|
|
||||||
|
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderToSvg, renderToPng } from "@json-render/image/render";
|
||||||
|
|
||||||
|
const svg = await renderToSvg(spec, { fonts });
|
||||||
|
const png = await renderToPng(spec, { fonts });
|
||||||
|
```
|
||||||
|
|
||||||
|
Nine standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. PNG output requires `@resvg/resvg-js` as an optional peer dependency.
|
||||||
|
|
||||||
|
See the [@json-render/image API reference](/docs/api/image) for details.
|
||||||
|
|
||||||
|
## React PDF
|
||||||
|
|
||||||
|
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
renderToBuffer,
|
||||||
|
renderToStream,
|
||||||
|
renderToFile,
|
||||||
|
} from "@json-render/react-pdf";
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(spec);
|
||||||
|
const stream = await renderToStream(spec);
|
||||||
|
await renderToFile(spec, "./output.pdf");
|
||||||
|
```
|
||||||
|
|
||||||
|
Standard components include Document, Page, View, Row, Column, Heading, Text, Image, Table, List, Divider, Spacer, Link, and PageNumber.
|
||||||
|
|
||||||
|
See the [@json-render/react-pdf API reference](/docs/api/react-pdf) for details.
|
||||||
|
|
||||||
|
## Remotion
|
||||||
|
|
||||||
|
Turn JSON timeline specs into video compositions with Remotion.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Player } from "@remotion/player";
|
||||||
|
import { Renderer } from "@json-render/remotion";
|
||||||
|
|
||||||
|
<Player
|
||||||
|
component={Renderer}
|
||||||
|
inputProps={{ spec }}
|
||||||
|
durationInFrames={spec.composition.durationInFrames}
|
||||||
|
fps={spec.composition.fps}
|
||||||
|
compositionWidth={spec.composition.width}
|
||||||
|
compositionHeight={spec.composition.height}
|
||||||
|
/>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
|
||||||
|
|
||||||
|
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
|
||||||
|
|
||||||
|
## Custom Renderers
|
||||||
|
|
||||||
|
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Schemas" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/schemas")
|
||||||
|
|
||||||
# Schemas
|
# Schemas
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata";
|
||||||
|
|
||||||
|
export const metadata = pageMetadata("docs/skills");
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
|
||||||
|
json-render ships with skills that teach AI coding agents how to use each package. Install a skill and your agent in Cursor, Claude Code, or Codex can generate json-render UIs without manual guidance.
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
|
||||||
|
- **core** — Core schemas, catalogs, and AI prompt generation.
|
||||||
|
- **react** — React renderer that turns JSON specs into React component trees.
|
||||||
|
- **react-pdf** — PDF renderer using `@react-pdf/renderer`.
|
||||||
|
- **react-email** — Email renderer that produces HTML or plain-text emails.
|
||||||
|
- **react-native** — React Native renderer for native mobile UIs.
|
||||||
|
- **shadcn** — Pre-built shadcn/ui components (Radix UI + Tailwind).
|
||||||
|
- **image** — Image renderer that turns JSON specs into SVG and PNG via Satori.
|
||||||
|
- **remotion** — Remotion renderer for video generation from JSON timeline specs.
|
||||||
|
- **vue** — Vue 3 renderer for Vue component trees.
|
||||||
|
- **svelte** — Svelte 5 renderer for Svelte component trees.
|
||||||
|
- **solid** — SolidJS renderer for fine-grained reactive component trees.
|
||||||
|
- **codegen** — Code generation utilities for building custom exporters.
|
||||||
|
- **mcp** — MCP Apps integration for Claude, ChatGPT, Cursor, and VS Code.
|
||||||
|
- **redux** — Redux adapter for json-render's `StateStore` interface.
|
||||||
|
- **zustand** — Zustand adapter for json-render's `StateStore` interface.
|
||||||
|
- **jotai** — Jotai adapter for json-render's `StateStore` interface.
|
||||||
|
- **xstate** — XState Store adapter for json-render's `StateStore` interface.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx skills add vercel-labs/json-render --skill core
|
||||||
|
npx skills add vercel-labs/json-render --skill react
|
||||||
|
npx skills add vercel-labs/json-render --skill react-pdf
|
||||||
|
npx skills add vercel-labs/json-render --skill react-email
|
||||||
|
npx skills add vercel-labs/json-render --skill react-native
|
||||||
|
npx skills add vercel-labs/json-render --skill shadcn
|
||||||
|
npx skills add vercel-labs/json-render --skill image
|
||||||
|
npx skills add vercel-labs/json-render --skill remotion
|
||||||
|
npx skills add vercel-labs/json-render --skill vue
|
||||||
|
npx skills add vercel-labs/json-render --skill svelte
|
||||||
|
npx skills add vercel-labs/json-render --skill solid
|
||||||
|
npx skills add vercel-labs/json-render --skill codegen
|
||||||
|
npx skills add vercel-labs/json-render --skill mcp
|
||||||
|
npx skills add vercel-labs/json-render --skill redux
|
||||||
|
npx skills add vercel-labs/json-render --skill zustand
|
||||||
|
npx skills add vercel-labs/json-render --skill jotai
|
||||||
|
npx skills add vercel-labs/json-render --skill xstate
|
||||||
|
```
|
||||||
|
|
||||||
|
After installing, your AI agent will automatically activate the right skill when it encounters a matching request.
|
||||||
|
|
||||||
|
## core
|
||||||
|
|
||||||
|
The foundational skill. Teaches agents how to define catalogs, create schemas, build specs, and generate AI prompts. This is the starting point for any json-render project and covers `defineCatalog`, `defineSchema`, `specSchema`, `toPrompt`, and the full spec format.
|
||||||
|
|
||||||
|
## react
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as React component trees using `JsonRender`, `JsonRenderClient`, and `useJsonRender`. Covers custom component registries, client-side interactivity, state management, and streaming integration.
|
||||||
|
|
||||||
|
## react-pdf
|
||||||
|
|
||||||
|
Teaches agents how to generate PDFs from JSON specs using `@react-pdf/renderer`. Covers the PDF-specific component registry, page layout, and styling.
|
||||||
|
|
||||||
|
## react-email
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as HTML or plain-text emails using React Email components. Covers the email-specific registry and rendering pipeline.
|
||||||
|
|
||||||
|
## react-native
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as native mobile UIs with React Native. Covers the native component registry and platform-specific considerations.
|
||||||
|
|
||||||
|
## shadcn
|
||||||
|
|
||||||
|
Teaches agents how to use the pre-built shadcn/ui component registry with json-render. Includes Radix UI primitives, Tailwind styling, and the full set of available shadcn components.
|
||||||
|
|
||||||
|
## image
|
||||||
|
|
||||||
|
Teaches agents how to turn JSON specs into SVG and PNG images using Satori. Covers the image-specific registry, dimensions, fonts, and rendering options.
|
||||||
|
|
||||||
|
## remotion
|
||||||
|
|
||||||
|
Teaches agents how to generate videos from JSON timeline specs using Remotion. Covers compositions, sequences, timeline structure, and video rendering.
|
||||||
|
|
||||||
|
## vue
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as Vue 3 component trees. Covers the Vue renderer API, custom component registries, and reactivity integration.
|
||||||
|
|
||||||
|
## svelte
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as Svelte 5 component trees. Covers the Svelte renderer API and component registration.
|
||||||
|
|
||||||
|
## solid
|
||||||
|
|
||||||
|
Teaches agents how to render JSON specs as SolidJS component trees. Covers Solid-specific reactive patterns, provider wiring, bindings, actions, and streaming.
|
||||||
|
|
||||||
|
## codegen
|
||||||
|
|
||||||
|
Teaches agents how to use code generation utilities to export UI specs as framework-specific source code. Covers the codegen pipeline and custom exporter creation.
|
||||||
|
|
||||||
|
## mcp
|
||||||
|
|
||||||
|
Teaches agents how to build MCP Apps that serve json-render UIs inside AI tools like Claude, ChatGPT, Cursor, and VS Code. Covers MCP server setup, tool definitions, and UI streaming.
|
||||||
|
|
||||||
|
## redux
|
||||||
|
|
||||||
|
Teaches agents how to connect a Redux store to json-render's `StateStore` interface for state-driven UIs.
|
||||||
|
|
||||||
|
## zustand
|
||||||
|
|
||||||
|
Teaches agents how to connect a Zustand store to json-render's `StateStore` interface for lightweight state management.
|
||||||
|
|
||||||
|
## jotai
|
||||||
|
|
||||||
|
Teaches agents how to connect Jotai atoms to json-render's `StateStore` interface for atomic state management.
|
||||||
|
|
||||||
|
## xstate
|
||||||
|
|
||||||
|
Teaches agents how to connect an XState Store to json-render's `StateStore` interface for state-machine-driven UIs.
|
||||||
|
|
||||||
|
## Source
|
||||||
|
|
||||||
|
All skill files are in the [`skills/`](https://github.com/vercel-labs/json-render/tree/main/skills) directory of the repository.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Specs" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/specs")
|
||||||
|
|
||||||
# Specs
|
# Specs
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Streaming" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/streaming")
|
||||||
|
|
||||||
# Streaming
|
# Streaming
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Validation" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/validation")
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
@@ -10,11 +11,18 @@ json-render includes common validation functions:
|
|||||||
|
|
||||||
- `required` — Value must be non-empty
|
- `required` — Value must be non-empty
|
||||||
- `email` — Valid email format
|
- `email` — Valid email format
|
||||||
- `minLength` — Minimum string length
|
- `minLength` — Minimum string length (args: `{ "min": N }`)
|
||||||
- `maxLength` — Maximum string length
|
- `maxLength` — Maximum string length (args: `{ "max": N }`)
|
||||||
- `pattern` — Match a regex pattern
|
- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
|
||||||
- `min` — Minimum numeric value
|
- `min` — Minimum numeric value (args: `{ "min": N }`)
|
||||||
- `max` — Maximum numeric value
|
- `max` — Maximum numeric value (args: `{ "max": N }`)
|
||||||
|
- `numeric` — Value must be a number
|
||||||
|
- `url` — Valid URL format
|
||||||
|
- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
|
||||||
|
- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
|
||||||
|
- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||||
|
- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
|
||||||
|
- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
|
||||||
|
|
||||||
## Using Validation in JSON
|
## Using Validation in JSON
|
||||||
|
|
||||||
@@ -66,7 +74,7 @@ Define custom validators in your catalog's `functions` field. The catalog itself
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { defineCatalog } from '@json-render/core';
|
import { defineCatalog } from '@json-render/core';
|
||||||
import { schema } from '@json-render/react'; // or '@json-render/react-native'
|
import { schema } from '@json-render/react/schema'; // or '@json-render/react-native/schema'
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const catalog = defineCatalog(schema, {
|
const catalog = defineCatalog(schema, {
|
||||||
@@ -142,14 +150,114 @@ function TextField({ props, bindings }) {
|
|||||||
|
|
||||||
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
|
||||||
|
|
||||||
|
## Cross-Field Validation
|
||||||
|
|
||||||
|
Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Input",
|
||||||
|
"props": {
|
||||||
|
"label": "Confirm Password",
|
||||||
|
"value": { "$bindState": "/form/confirmPassword" },
|
||||||
|
"checks": [
|
||||||
|
{ "type": "required", "message": "Please confirm your password" },
|
||||||
|
{
|
||||||
|
"type": "matches",
|
||||||
|
"args": { "other": { "$state": "/form/password" } },
|
||||||
|
"message": "Passwords must match"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Other cross-field examples:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"type": "greaterThan",
|
||||||
|
"args": { "other": { "$state": "/form/startDate" } },
|
||||||
|
"message": "End date must be after start date"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"type": "requiredIf",
|
||||||
|
"args": { "field": { "$state": "/form/enableNotifications" } },
|
||||||
|
"message": "Email is required when notifications are enabled"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conditional Validation
|
||||||
|
|
||||||
|
Use the `enabled` field in the validation config to only run checks when a condition is met:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Input",
|
||||||
|
"props": {
|
||||||
|
"label": "Company Name",
|
||||||
|
"value": { "$bindState": "/form/company" },
|
||||||
|
"checks": [
|
||||||
|
{ "type": "required", "message": "Company name is required" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the component implementation, you can pass `enabled` to `useFieldValidation`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
useFieldValidation(bindings?.value ?? "", {
|
||||||
|
checks: props.checks ?? [],
|
||||||
|
enabled: { "$state": "/form/accountType", eq: "business" },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This only validates the company name when the account type is "business".
|
||||||
|
|
||||||
## Validation Timing
|
## Validation Timing
|
||||||
|
|
||||||
Control when validation runs with `validateOn`:
|
Control when validation runs with `validateOn`:
|
||||||
|
|
||||||
- `change` — Validate on every input change
|
- `change` — Validate on every input change
|
||||||
- `blur` — Validate when field loses focus
|
- `blur` — Validate when field loses focus (default for Input, Textarea)
|
||||||
- `submit` — Validate only on form submission
|
- `submit` — Validate only on form submission
|
||||||
|
|
||||||
|
## Form-Level Validation
|
||||||
|
|
||||||
|
Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Button",
|
||||||
|
"props": { "label": "Submit" },
|
||||||
|
"on": {
|
||||||
|
"press": [
|
||||||
|
{ "action": "validateForm", "params": { "statePath": "/formResult" } },
|
||||||
|
{ "action": "submitForm" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
|
||||||
|
|
||||||
|
> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
Learn about [generation modes](/docs/generation-modes).
|
- [Computed Values](/docs/computed-values) — derive dynamic prop values
|
||||||
|
- [Watchers](/docs/watchers) — react to state changes
|
||||||
|
- [Generation Modes](/docs/generation-modes) — how AI generates specs
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Visibility" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/visibility")
|
||||||
|
|
||||||
# Visibility
|
# Visibility
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/watchers")
|
||||||
|
|
||||||
|
# Watchers
|
||||||
|
|
||||||
|
React to state changes by triggering actions when watched paths update.
|
||||||
|
|
||||||
|
## The `watch` Field
|
||||||
|
|
||||||
|
Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
|
||||||
|
|
||||||
|
`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Select",
|
||||||
|
"props": {
|
||||||
|
"label": "Country",
|
||||||
|
"value": { "$bindState": "/form/country" },
|
||||||
|
"options": ["US", "Canada", "UK"]
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"/form/country": {
|
||||||
|
"action": "loadCities",
|
||||||
|
"params": { "country": { "$state": "/form/country" } }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
|
||||||
|
|
||||||
|
## Cascading Selects
|
||||||
|
|
||||||
|
A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"root": "form",
|
||||||
|
"elements": {
|
||||||
|
"form": {
|
||||||
|
"type": "Stack",
|
||||||
|
"props": { "direction": "vertical", "gap": "md" },
|
||||||
|
"children": ["country-select", "city-select"]
|
||||||
|
},
|
||||||
|
"country-select": {
|
||||||
|
"type": "Select",
|
||||||
|
"props": {
|
||||||
|
"label": "Country",
|
||||||
|
"value": { "$bindState": "/form/country" },
|
||||||
|
"options": ["US", "Canada", "UK"]
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"/form/country": [
|
||||||
|
{ "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
|
||||||
|
{ "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
"city-select": {
|
||||||
|
"type": "Select",
|
||||||
|
"props": {
|
||||||
|
"label": "City",
|
||||||
|
"value": { "$bindState": "/form/city" },
|
||||||
|
"options": { "$state": "/availableCities" },
|
||||||
|
"placeholder": "Select a city"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"form": { "country": "", "city": "" },
|
||||||
|
"availableCities": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The watcher on `country-select` fires two actions when the country changes:
|
||||||
|
1. `loadCities` — fetches and writes city options to `/availableCities`
|
||||||
|
2. `setState` — resets the city selection
|
||||||
|
|
||||||
|
The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
|
||||||
|
|
||||||
|
### Action Handler
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handlers = {
|
||||||
|
loadCities: async (params) => {
|
||||||
|
const cities = await fetchCities(params.country);
|
||||||
|
// setState is called by the runtime to write the result
|
||||||
|
return cities;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with `defineRegistry`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { registry, handlers } = defineRegistry(catalog, {
|
||||||
|
components: { /* ... */ },
|
||||||
|
actions: {
|
||||||
|
loadCities: async (params, setState) => {
|
||||||
|
const response = await fetch(`/api/cities?country=${params.country}`);
|
||||||
|
const cities = await response.json();
|
||||||
|
setState('/availableCities', cities);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Multiple Watchers
|
||||||
|
|
||||||
|
An element can watch multiple state paths. Each path maps to one or more action bindings:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"watch": {
|
||||||
|
"/form/startDate": { "action": "validateDateRange" },
|
||||||
|
"/form/endDate": { "action": "validateDateRange" },
|
||||||
|
"/form/quantity": [
|
||||||
|
{ "action": "recalculateTotal" },
|
||||||
|
{ "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Behavior
|
||||||
|
|
||||||
|
- Watchers only fire on **value changes**, not on the initial render
|
||||||
|
- Comparison is by reference (`===`), not deep equality
|
||||||
|
- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
|
||||||
|
- Multiple action bindings on the same path execute sequentially
|
||||||
|
|
||||||
|
## When to Use `watch` vs `on`
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Mechanism</th>
|
||||||
|
<th>Trigger</th>
|
||||||
|
<th>Use Case</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>on</code></td>
|
||||||
|
<td>User interaction (press, change, blur)</td>
|
||||||
|
<td>Button clicks, input changes, form submissions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>watch</code></td>
|
||||||
|
<td>State value change (any source)</td>
|
||||||
|
<td>Cascading data, derived state, cross-field sync</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
- [Data Binding](/docs/data-binding) — connect elements to state
|
||||||
|
- [Computed Values](/docs/computed-values) — derive prop values
|
||||||
|
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||||
@@ -16,7 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
|||||||
|
|
||||||
GitHub repository: https://github.com/vercel-labs/json-render
|
GitHub repository: https://github.com/vercel-labs/json-render
|
||||||
Documentation: https://json-render.dev/docs
|
Documentation: https://json-render.dev/docs
|
||||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||||
|
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||||
|
|
||||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +1,73 @@
|
|||||||
import { streamText } from "ai";
|
import { streamText } from "ai";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { buildUserPrompt } from "@json-render/core";
|
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 { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
|
||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
|
|
||||||
export const maxDuration = 30;
|
export const maxDuration = 30;
|
||||||
|
|
||||||
const SYSTEM_PROMPT = playgroundCatalog.prompt({
|
const PLAYGROUND_RULES = [
|
||||||
customRules: [
|
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
|
||||||
"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.",
|
||||||
"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 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.",
|
||||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
"Wrap each repeated item in a Card for visual separation and structure.",
|
||||||
"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.",
|
||||||
"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" }).',
|
||||||
'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" }).',
|
];
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const MAX_PROMPT_LENGTH = 500;
|
const MAX_PROMPT_LENGTH = 500;
|
||||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
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) {
|
export async function POST(req: Request) {
|
||||||
// Get client IP for rate limiting
|
|
||||||
const headersList = await headers();
|
const headersList = await headers();
|
||||||
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
|
||||||
|
|
||||||
// Check rate limits (minute and daily)
|
|
||||||
const [minuteResult, dailyResult] = await Promise.all([
|
const [minuteResult, dailyResult] = await Promise.all([
|
||||||
minuteRateLimit.limit(ip),
|
minuteRateLimit.limit(ip),
|
||||||
dailyRateLimit.limit(ip),
|
dailyRateLimit.limit(ip),
|
||||||
@@ -48,22 +89,34 @@ export async function POST(req: Request) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { prompt, context } = await req.json();
|
const { prompt, context, format, editModes } = await req.json();
|
||||||
|
const isYaml = format === "yaml";
|
||||||
|
|
||||||
const userPrompt = buildUserPrompt({
|
const systemPrompt = getSystemPrompt(isYaml, editModes);
|
||||||
prompt,
|
const userPrompt = isYaml
|
||||||
currentSpec: context?.previousSpec,
|
? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
|
||||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
: buildUserPrompt({
|
||||||
});
|
prompt,
|
||||||
|
currentSpec: context?.previousSpec,
|
||||||
|
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||||
|
editModes,
|
||||||
|
});
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
|
||||||
system: SYSTEM_PROMPT,
|
system: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: systemPrompt,
|
||||||
|
providerOptions: {
|
||||||
|
anthropic: { cacheControl: { type: "ephemeral" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream the text, then append token usage metadata at the end
|
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const textStream = result.textStream;
|
const textStream = result.textStream;
|
||||||
|
|
||||||
@@ -72,7 +125,6 @@ export async function POST(req: Request) {
|
|||||||
for await (const chunk of textStream) {
|
for await (const chunk of textStream) {
|
||||||
controller.enqueue(encoder.encode(chunk));
|
controller.enqueue(encoder.encode(chunk));
|
||||||
}
|
}
|
||||||
// Append usage metadata after stream completes
|
|
||||||
try {
|
try {
|
||||||
const usage = await result.usage;
|
const usage = await result.usage;
|
||||||
const meta = JSON.stringify({
|
const meta = JSON.stringify({
|
||||||
@@ -80,10 +132,12 @@ export async function POST(req: Request) {
|
|||||||
promptTokens: usage.inputTokens,
|
promptTokens: usage.inputTokens,
|
||||||
completionTokens: usage.outputTokens,
|
completionTokens: usage.outputTokens,
|
||||||
totalTokens: usage.totalTokens,
|
totalTokens: usage.totalTokens,
|
||||||
|
cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
|
||||||
|
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
|
||||||
});
|
});
|
||||||
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
controller.enqueue(encoder.encode(`\n${meta}\n`));
|
||||||
} catch {
|
} catch {
|
||||||
// Usage not available — skip silently
|
// Usage not available
|
||||||
}
|
}
|
||||||
controller.close();
|
controller.close();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -154,23 +154,34 @@ button {
|
|||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MDX table styles — fallback for GFM-generated tables */
|
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
|
||||||
.mdx-table th,
|
.mdx-table th,
|
||||||
.mdx-table td {
|
.mdx-table td,
|
||||||
|
article table th,
|
||||||
|
article table td {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdx-table th {
|
.mdx-table th,
|
||||||
|
article table th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background-color: var(--muted);
|
background-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdx-table td {
|
.mdx-table td,
|
||||||
|
article table td {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article table {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shiki dual theme support */
|
/* Shiki dual theme support */
|
||||||
.shiki,
|
.shiki,
|
||||||
.shiki span {
|
.shiki span {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import { GeistPixelSquare } from "geist/font/pixel";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { DocsChat } from "@/components/docs-chat";
|
import { DocsChat } from "@/components/docs-chat";
|
||||||
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
|
|||||||
description:
|
description:
|
||||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||||
images: ["/og"],
|
images: ["/og"],
|
||||||
creator: "@verabornnot",
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -92,7 +92,9 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
|
||||||
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import { join } from "node:path";
|
|||||||
export { getPageTitle } from "@/lib/page-titles";
|
export { getPageTitle } from "@/lib/page-titles";
|
||||||
|
|
||||||
// Cache font data in memory after first load
|
// Cache font data in memory after first load
|
||||||
let fontCache: { geistRegular: Buffer } | null = null;
|
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
|
||||||
|
|
||||||
async function loadFonts() {
|
async function loadFonts() {
|
||||||
if (fontCache) return fontCache;
|
if (fontCache) return fontCache;
|
||||||
const geistRegular = await readFile(
|
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||||
join(process.cwd(), "public/Geist-Regular.ttf"),
|
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||||
);
|
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||||
fontCache = { geistRegular };
|
]);
|
||||||
|
fontCache = { geistRegular, geistPixelSquare };
|
||||||
return fontCache;
|
return fontCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderOgImage(title: string) {
|
export async function renderOgImage(title: string) {
|
||||||
const { geistRegular } = await loadFonts();
|
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<div
|
<div
|
||||||
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontFamily: "Geist",
|
fontFamily: "Geist Pixel Square",
|
||||||
fontWeight: 400,
|
fontWeight: 500,
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
|
|||||||
style: "normal",
|
style: "normal",
|
||||||
weight: 400,
|
weight: 400,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Geist Pixel Square",
|
||||||
|
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||||
|
style: "normal",
|
||||||
|
weight: 500,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Playground } from "@/components/playground";
|
import { Playground } from "@/components/playground";
|
||||||
|
import { pageMetadata } from "@/lib/page-metadata";
|
||||||
|
|
||||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
export const metadata = pageMetadata("playground");
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: PAGE_TITLES["playground"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
export default function PlaygroundPage() {
|
||||||
return <Playground />;
|
return <Playground />;
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ function getHighlighter() {
|
|||||||
if (!highlighterPromise) {
|
if (!highlighterPromise) {
|
||||||
highlighterPromise = createHighlighter({
|
highlighterPromise = createHighlighter({
|
||||||
themes: [vercelLightTheme, vercelDarkTheme],
|
themes: [vercelLightTheme, vercelDarkTheme],
|
||||||
langs: ["json", "tsx", "typescript"],
|
langs: ["json", "tsx", "typescript", "yaml"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return highlighterPromise;
|
return highlighterPromise;
|
||||||
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
|
|||||||
|
|
||||||
interface CodeBlockProps {
|
interface CodeBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
lang: "json" | "tsx" | "typescript";
|
lang: "json" | "tsx" | "typescript" | "yaml";
|
||||||
fillHeight?: boolean;
|
fillHeight?: boolean;
|
||||||
hideCopyButton?: boolean;
|
hideCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,257 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
function Skeleton({ className = "" }: { className?: string }) {
|
function ScheduleItem({
|
||||||
return <div className={`rounded bg-muted-foreground/10 ${className}`} />;
|
time,
|
||||||
}
|
label,
|
||||||
|
color,
|
||||||
/** Simple form wireframe reused in both diagrams */
|
}: {
|
||||||
function FormUI() {
|
time: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-border rounded-lg p-2.5 space-y-1.5 bg-muted/30">
|
<div className="flex items-center gap-2 py-1.5 border-t border-border/50 first:border-t-0">
|
||||||
{/* Title */}
|
<span className="text-[10px] text-muted-foreground/60 w-12 shrink-0 tabular-nums">
|
||||||
<Skeleton className="h-2.5 w-14 mb-1" />
|
{time}
|
||||||
{/* Input fields */}
|
</span>
|
||||||
<Skeleton className="h-4 w-full rounded-sm" />
|
<span
|
||||||
<Skeleton className="h-4 w-full rounded-sm" />
|
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
{/* Submit button */}
|
style={{ background: color }}
|
||||||
<Skeleton className="h-4 w-16 rounded-sm bg-muted-foreground/20 mt-1" />
|
/>
|
||||||
|
<span className="text-[11px] text-muted-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChatModeDiagram() {
|
function InlineModeDiagram() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
<div className="text-sm font-medium text-foreground text-center mb-3">
|
||||||
Chat Mode
|
Inline Mode
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-col">
|
<div className="flex-1 border border-border rounded-2xl bg-background overflow-hidden flex flex-col">
|
||||||
{/* Chat area */}
|
<div className="flex-1 p-4 space-y-3 overflow-hidden">
|
||||||
<div className="flex-1 p-3 overflow-hidden flex justify-center">
|
{/* User message */}
|
||||||
<div className="w-1/3 min-w-[120px] space-y-3">
|
<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's what you'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'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 */}
|
{/* User message */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<div className="bg-muted rounded-xl px-3 py-2">
|
<div className="bg-muted-foreground/20 rounded-2xl rounded-br px-2.5 py-1.5">
|
||||||
<Skeleton className="h-2 w-14" />
|
<span className="text-[10px] text-foreground/80 block leading-relaxed">
|
||||||
|
A landing page for my analytics startup
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* AI text */}
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 leading-relaxed">
|
||||||
|
Here's a clean landing page with a hero, nav, and CTAs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Assistant text */}
|
{/* Prompt input */}
|
||||||
<div className="space-y-2">
|
<div className="p-2.5">
|
||||||
<div className="space-y-1.5">
|
<div className="bg-muted/50 border border-border rounded-xl px-2.5 py-1.5 flex items-center gap-1.5">
|
||||||
<Skeleton className="h-2 w-full" />
|
<span className="text-[10px] text-muted-foreground/40 flex-1">
|
||||||
<Skeleton className="h-2 w-3/4" />
|
Message...
|
||||||
</div>
|
</span>
|
||||||
|
<div className="w-5 h-5 rounded-md bg-muted-foreground/20 flex items-center justify-center">
|
||||||
{/* Inline UI */}
|
<svg
|
||||||
<FormUI />
|
className="w-2.5 h-2.5 text-muted-foreground/50"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
{/* More text after UI */}
|
fill="currentColor"
|
||||||
<div className="space-y-1.5">
|
>
|
||||||
<Skeleton className="h-2 w-4/5" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input bar */}
|
{/* Right panel - landing page */}
|
||||||
<div className="p-2 flex justify-center">
|
<div className="flex-1">
|
||||||
<div className="w-1/3 min-w-[120px] flex items-center gap-2">
|
<LandingPage />
|
||||||
<Skeleton className="h-7 flex-1 rounded-md" />
|
|
||||||
<Skeleton className="h-7 w-7 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
<div className="mt-3 flex flex-col items-center gap-2">
|
||||||
Text + UI interleaved in messages
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
</div>
|
Prompt in, UI out
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GenerateModeDiagram() {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="text-xs font-medium text-muted-foreground mb-3 text-center">
|
|
||||||
Generate Mode
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 border border-border rounded-lg bg-background overflow-hidden flex flex-row">
|
|
||||||
{/* Left panel - prompt */}
|
|
||||||
<div className="w-[38%] border-r border-border flex flex-col">
|
|
||||||
<div className="flex-1" />
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
<Skeleton className="h-7 w-full rounded-md" />
|
|
||||||
<Skeleton className="h-5 w-16 rounded-md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50">
|
||||||
{/* Right panel - UI preview */}
|
<span>Website builders</span>
|
||||||
<div className="flex-1 p-3 flex items-center justify-center">
|
<span className="text-muted-foreground/30">/</span>
|
||||||
<div className="w-3/4">
|
<span>Text-to-widget</span>
|
||||||
<FormUI />
|
<span className="text-muted-foreground/30">/</span>
|
||||||
</div>
|
<span>Dashboards</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground/60 mt-2 text-center">
|
|
||||||
Prompt separate from UI preview
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,11 +260,11 @@ export function GenerationModesDiagram() {
|
|||||||
return (
|
return (
|
||||||
<div className="not-prose my-8">
|
<div className="not-prose my-8">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
<div className="h-[280px]">
|
<div className="h-[420px]">
|
||||||
<ChatModeDiagram />
|
<InlineModeDiagram />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[280px]">
|
<div className="h-[420px]">
|
||||||
<GenerateModeDiagram />
|
<StandaloneModeDiagram />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function Header() {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<span className="font-medium tracking-tight text-lg">
|
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
|
||||||
json-render
|
json-render
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -100,7 +100,7 @@ export function Header() {
|
|||||||
>
|
>
|
||||||
<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" />
|
<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>
|
</svg>
|
||||||
<span>11k</span>
|
<span>12k</span>
|
||||||
</a>
|
</a>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -2,10 +2,16 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
|
||||||
import { flushSync } from "react-dom";
|
import { flushSync } from "react-dom";
|
||||||
import { useUIStream, type TokenUsage } from "@json-render/react";
|
|
||||||
import type { Spec } from "@json-render/core";
|
import type { Spec } from "@json-render/core";
|
||||||
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { stringify as yamlStringify } from "yaml";
|
||||||
|
import type { EditMode } from "@json-render/core";
|
||||||
|
import {
|
||||||
|
usePlaygroundStream,
|
||||||
|
type StreamFormat,
|
||||||
|
type TokenUsage,
|
||||||
|
} from "@/lib/use-playground-stream";
|
||||||
import {
|
import {
|
||||||
ResizablePanelGroup,
|
ResizablePanelGroup,
|
||||||
ResizablePanel,
|
ResizablePanel,
|
||||||
@@ -16,17 +22,20 @@ import { CopyButton } from "./copy-button";
|
|||||||
import { Toaster } from "./ui/sonner";
|
import { Toaster } from "./ui/sonner";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||||
|
import { JsonEditor } from "@visual-json/react";
|
||||||
|
import type { JsonValue } from "@visual-json/react";
|
||||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||||
|
|
||||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
type Tab = "spec" | "nested" | "stream" | "catalog" | "visual";
|
||||||
type RenderView = "preview" | "code";
|
type RenderView = "preview" | "code";
|
||||||
type MobileView =
|
type MobileView =
|
||||||
| "json"
|
| "spec"
|
||||||
| "nested"
|
| "nested"
|
||||||
| "stream"
|
| "stream"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
|
| "visual"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "generated-code";
|
| "generated-code";
|
||||||
|
|
||||||
@@ -37,6 +46,79 @@ interface Version {
|
|||||||
status: "generating" | "complete" | "error";
|
status: "generating" | "complete" | "error";
|
||||||
usage: TokenUsage | null;
|
usage: TokenUsage | null;
|
||||||
rawLines: string[];
|
rawLines: string[];
|
||||||
|
format: StreamFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n: number): string {
|
||||||
|
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaygroundControls({
|
||||||
|
format,
|
||||||
|
setFormat,
|
||||||
|
editModes,
|
||||||
|
setEditModes,
|
||||||
|
showClear,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
format: StreamFormat;
|
||||||
|
setFormat: (f: StreamFormat) => void;
|
||||||
|
editModes: EditMode[];
|
||||||
|
setEditModes: React.Dispatch<React.SetStateAction<EditMode[]>>;
|
||||||
|
showClear: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
||||||
|
{(["jsonl", "yaml"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFormat(f)}
|
||||||
|
className={`px-1.5 py-0.5 transition-colors ${
|
||||||
|
format === f
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
|
||||||
|
{(["patch", "merge", "diff"] as const).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => {
|
||||||
|
setEditModes((prev) =>
|
||||||
|
prev.includes(m)
|
||||||
|
? prev.length > 1
|
||||||
|
? prev.filter((x) => x !== m)
|
||||||
|
: prev
|
||||||
|
: [...prev, m],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={`px-1.5 py-0.5 transition-colors ${
|
||||||
|
editModes.includes(m)
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{showClear && (
|
||||||
|
<button
|
||||||
|
onClick={onClear}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -97,13 +179,15 @@ export function Playground() {
|
|||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [inputValue, setInputValue] = useState("");
|
const [inputValue, setInputValue] = useState("");
|
||||||
const [activeTab, setActiveTab] = useState<Tab>("json");
|
const [activeTab, setActiveTab] = useState<Tab>("spec");
|
||||||
const [catalogSection, setCatalogSection] = useState<
|
const [catalogSection, setCatalogSection] = useState<
|
||||||
"components" | "actions"
|
"components" | "actions"
|
||||||
>("components");
|
>("components");
|
||||||
const [renderView, setRenderView] = useState<RenderView>("preview");
|
const [renderView, setRenderView] = useState<RenderView>("preview");
|
||||||
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||||
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
|
||||||
|
const [format, setFormat] = useState<StreamFormat>("jsonl");
|
||||||
|
const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const versionsEndRef = useRef<HTMLDivElement>(null);
|
const versionsEndRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -121,12 +205,13 @@ export function Playground() {
|
|||||||
rawLines: streamRawLines,
|
rawLines: streamRawLines,
|
||||||
send,
|
send,
|
||||||
clear,
|
clear,
|
||||||
} = useUIStream({
|
} = usePlaygroundStream({
|
||||||
api: "/api/generate",
|
api: "/api/generate",
|
||||||
|
format,
|
||||||
|
editModes,
|
||||||
onError: (err: Error) => {
|
onError: (err: Error) => {
|
||||||
console.error("Generation error:", err);
|
console.error("Generation error:", err);
|
||||||
toast.error(err.message || "Generation failed. Please try again.");
|
toast.error(err.message || "Generation failed. Please try again.");
|
||||||
// Mark the version as errored
|
|
||||||
if (generatingVersionIdRef.current) {
|
if (generatingVersionIdRef.current) {
|
||||||
const erroredVersionId = generatingVersionIdRef.current;
|
const erroredVersionId = generatingVersionIdRef.current;
|
||||||
setVersions((prev) =>
|
setVersions((prev) =>
|
||||||
@@ -137,7 +222,7 @@ export function Playground() {
|
|||||||
generatingVersionIdRef.current = null;
|
generatingVersionIdRef.current = null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as Parameters<typeof useUIStream>[0]);
|
});
|
||||||
|
|
||||||
// Get the selected version
|
// Get the selected version
|
||||||
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
|
||||||
@@ -212,6 +297,7 @@ export function Playground() {
|
|||||||
status: "generating",
|
status: "generating",
|
||||||
usage: null,
|
usage: null,
|
||||||
rawLines: [],
|
rawLines: [],
|
||||||
|
format,
|
||||||
};
|
};
|
||||||
|
|
||||||
generatingVersionIdRef.current = newVersionId;
|
generatingVersionIdRef.current = newVersionId;
|
||||||
@@ -221,7 +307,7 @@ export function Playground() {
|
|||||||
|
|
||||||
// Pass the current tree as context so the API can iterate on it
|
// Pass the current tree as context so the API can iterate on it
|
||||||
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
|
||||||
}, [inputValue, isStreaming, send]);
|
}, [inputValue, isStreaming, send, format]);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent) => {
|
(e: React.KeyboardEvent) => {
|
||||||
@@ -233,9 +319,30 @@ export function Playground() {
|
|||||||
[handleSubmit],
|
[handleSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
const jsonCode = currentTree
|
const handleVisualChange = useCallback(
|
||||||
? JSON.stringify(currentTree, null, 2)
|
(value: JsonValue) => {
|
||||||
: "// waiting...";
|
if (!selectedVersionId || isStreaming) return;
|
||||||
|
setVersions((prev) =>
|
||||||
|
prev.map((v) =>
|
||||||
|
v.id === selectedVersionId
|
||||||
|
? { ...v, tree: value as unknown as Spec }
|
||||||
|
: v,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedVersionId, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
|
const specCode = useMemo(() => {
|
||||||
|
if (!currentTree)
|
||||||
|
return format === "yaml" ? "# waiting..." : "// waiting...";
|
||||||
|
if (format === "yaml") {
|
||||||
|
return yamlStringify(currentTree, { indent: 2 }).trimEnd();
|
||||||
|
}
|
||||||
|
return JSON.stringify(currentTree, null, 2);
|
||||||
|
}, [currentTree, format]);
|
||||||
|
|
||||||
|
const specLang = format === "yaml" ? "yaml" : "json";
|
||||||
|
|
||||||
const nestedCode = useMemo(() => {
|
const nestedCode = useMemo(() => {
|
||||||
if (!currentTree || !currentTree.root) return "// waiting...";
|
if (!currentTree || !currentTree.root) return "// waiting...";
|
||||||
@@ -258,7 +365,7 @@ export function Playground() {
|
|||||||
const componentName = element.type;
|
const componentName = element.type;
|
||||||
|
|
||||||
const propsObj: Record<string, unknown> = {};
|
const propsObj: Record<string, unknown> = {};
|
||||||
for (const [k, v] of Object.entries(element.props)) {
|
for (const [k, v] of Object.entries(element.props ?? {})) {
|
||||||
if (v !== null && v !== undefined) {
|
if (v !== null && v !== undefined) {
|
||||||
propsObj[k] = v;
|
propsObj[k] = v;
|
||||||
}
|
}
|
||||||
@@ -304,6 +411,15 @@ ${jsx}
|
|||||||
}`;
|
}`;
|
||||||
}, [currentTree]);
|
}, [currentTree]);
|
||||||
|
|
||||||
|
// Determine syntax lang for raw stream based on selected version's format
|
||||||
|
const streamLang = isSelectedVersionGenerating
|
||||||
|
? format === "yaml"
|
||||||
|
? "yaml"
|
||||||
|
: "json"
|
||||||
|
: selectedVersion?.format === "yaml"
|
||||||
|
? "yaml"
|
||||||
|
: "json";
|
||||||
|
|
||||||
// Chat pane content
|
// Chat pane content
|
||||||
const chatPane = (
|
const chatPane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
@@ -375,9 +491,15 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
<div className="mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{version.usage.totalTokens.toLocaleString()} tokens
|
{formatTokens(
|
||||||
|
version.usage.promptTokens - version.usage.cachedTokens,
|
||||||
|
)}{" "}
|
||||||
|
in · {formatTokens(version.usage.completionTokens)} out
|
||||||
|
{version.usage.cachedTokens > 0
|
||||||
|
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -408,20 +530,18 @@ ${jsx}
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
{versions.length > 0 ? (
|
<PlaygroundControls
|
||||||
<button
|
format={format}
|
||||||
onClick={() => {
|
setFormat={setFormat}
|
||||||
setVersions([]);
|
editModes={editModes}
|
||||||
setSelectedVersionId(null);
|
setEditModes={setEditModes}
|
||||||
clear();
|
showClear={versions.length > 0}
|
||||||
}}
|
onClear={() => {
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
setVersions([]);
|
||||||
>
|
setSelectedVersionId(null);
|
||||||
Clear
|
clear();
|
||||||
</button>
|
}}
|
||||||
) : (
|
/>
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -475,35 +595,73 @@ ${jsx}
|
|||||||
const copyText =
|
const copyText =
|
||||||
activeTab === "stream"
|
activeTab === "stream"
|
||||||
? currentRawLines.join("\n")
|
? currentRawLines.join("\n")
|
||||||
: activeTab === "json"
|
: activeTab === "spec"
|
||||||
? jsonCode
|
? specCode
|
||||||
: activeTab === "nested"
|
: activeTab === "nested"
|
||||||
? nestedCode
|
? nestedCode
|
||||||
: "";
|
: activeTab === "visual"
|
||||||
|
? specCode
|
||||||
|
: "";
|
||||||
|
|
||||||
const codePane = (
|
const codePane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
<button
|
(tab) => (
|
||||||
key={tab}
|
<button
|
||||||
onClick={() => setActiveTab(tab)}
|
key={tab}
|
||||||
className={`text-xs font-mono transition-colors ${
|
onClick={() => setActiveTab(tab)}
|
||||||
activeTab === tab
|
className={`text-xs font-mono transition-colors ${
|
||||||
? "text-foreground"
|
activeTab === tab
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-foreground"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
{tab}
|
>
|
||||||
</button>
|
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
||||||
))}
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{activeTab !== "catalog" && (
|
{activeTab !== "catalog" && activeTab !== "visual" && (
|
||||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{activeTab === "catalog" ? (
|
{activeTab === "visual" ? (
|
||||||
|
currentTree ? (
|
||||||
|
<JsonEditor
|
||||||
|
value={currentTree as unknown as JsonValue}
|
||||||
|
onChange={handleVisualChange}
|
||||||
|
readOnly={isStreaming}
|
||||||
|
sidebarOpen={false}
|
||||||
|
height="100%"
|
||||||
|
className="h-full"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--vj-bg": "var(--background)",
|
||||||
|
"--vj-bg-panel": "var(--background)",
|
||||||
|
"--vj-bg-hover": "var(--muted)",
|
||||||
|
"--vj-bg-selected": "var(--primary)",
|
||||||
|
"--vj-bg-selected-muted": "var(--muted)",
|
||||||
|
"--vj-text": "var(--foreground)",
|
||||||
|
"--vj-text-selected": "var(--primary-foreground)",
|
||||||
|
"--vj-text-muted": "var(--muted-foreground)",
|
||||||
|
"--vj-text-dim": "var(--muted-foreground)",
|
||||||
|
"--vj-border": "var(--border)",
|
||||||
|
"--vj-border-subtle": "var(--border)",
|
||||||
|
"--vj-accent": "var(--primary)",
|
||||||
|
"--vj-accent-muted": "var(--muted)",
|
||||||
|
"--vj-input-bg": "var(--secondary)",
|
||||||
|
"--vj-input-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||||
|
{"// generate a spec to edit visually"}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : activeTab === "catalog" ? (
|
||||||
<div className="h-full flex flex-col text-sm">
|
<div className="h-full flex flex-col text-sm">
|
||||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||||
{(
|
{(
|
||||||
@@ -624,7 +782,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang="json"
|
lang={streamLang}
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -636,7 +794,12 @@ ${jsx}
|
|||||||
) : activeTab === "nested" ? (
|
) : activeTab === "nested" ? (
|
||||||
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
|
||||||
) : (
|
) : (
|
||||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock
|
||||||
|
code={specCode}
|
||||||
|
lang={specLang}
|
||||||
|
fillHeight
|
||||||
|
hideCopyButton
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -735,19 +898,21 @@ ${jsx}
|
|||||||
: 0}
|
: 0}
|
||||||
</button>
|
</button>
|
||||||
{/* Code tabs */}
|
{/* Code tabs */}
|
||||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
<button
|
(tab) => (
|
||||||
key={tab}
|
<button
|
||||||
onClick={() => setMobileView(tab)}
|
key={tab}
|
||||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
onClick={() => setMobileView(tab)}
|
||||||
mobileView === tab
|
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||||
? "text-foreground"
|
mobileView === tab
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-foreground"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
{tab}
|
>
|
||||||
</button>
|
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
|
||||||
))}
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Preview / code toggle */}
|
{/* Preview / code toggle */}
|
||||||
{[
|
{[
|
||||||
@@ -770,7 +935,41 @@ ${jsx}
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
{mobileView === "catalog" ? (
|
{mobileView === "visual" ? (
|
||||||
|
currentTree ? (
|
||||||
|
<JsonEditor
|
||||||
|
value={currentTree as unknown as JsonValue}
|
||||||
|
onChange={handleVisualChange}
|
||||||
|
readOnly={isStreaming}
|
||||||
|
sidebarOpen={false}
|
||||||
|
height="100%"
|
||||||
|
className="h-full"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--vj-bg": "var(--background)",
|
||||||
|
"--vj-bg-panel": "var(--background)",
|
||||||
|
"--vj-bg-hover": "var(--muted)",
|
||||||
|
"--vj-bg-selected": "var(--primary)",
|
||||||
|
"--vj-bg-selected-muted": "var(--muted)",
|
||||||
|
"--vj-text": "var(--foreground)",
|
||||||
|
"--vj-text-selected": "var(--primary-foreground)",
|
||||||
|
"--vj-text-muted": "var(--muted-foreground)",
|
||||||
|
"--vj-text-dim": "var(--muted-foreground)",
|
||||||
|
"--vj-border": "var(--border)",
|
||||||
|
"--vj-border-subtle": "var(--border)",
|
||||||
|
"--vj-accent": "var(--primary)",
|
||||||
|
"--vj-accent-muted": "var(--muted)",
|
||||||
|
"--vj-input-bg": "var(--secondary)",
|
||||||
|
"--vj-input-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||||
|
{"// generate a spec to edit visually"}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : mobileView === "catalog" ? (
|
||||||
<div className="h-full flex flex-col text-sm">
|
<div className="h-full flex flex-col text-sm">
|
||||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||||
{(
|
{(
|
||||||
@@ -891,7 +1090,7 @@ ${jsx}
|
|||||||
currentRawLines.length > 0 ? (
|
currentRawLines.length > 0 ? (
|
||||||
<CodeBlock
|
<CodeBlock
|
||||||
code={currentRawLines.join("\n")}
|
code={currentRawLines.join("\n")}
|
||||||
lang="json"
|
lang={streamLang}
|
||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
@@ -907,8 +1106,13 @@ ${jsx}
|
|||||||
fillHeight
|
fillHeight
|
||||||
hideCopyButton
|
hideCopyButton
|
||||||
/>
|
/>
|
||||||
) : mobileView === "json" ? (
|
) : mobileView === "spec" ? (
|
||||||
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
|
<CodeBlock
|
||||||
|
code={specCode}
|
||||||
|
lang={specLang}
|
||||||
|
fillHeight
|
||||||
|
hideCopyButton
|
||||||
|
/>
|
||||||
) : mobileView === "preview" ? (
|
) : mobileView === "preview" ? (
|
||||||
currentTree && currentTree.root ? (
|
currentTree && currentTree.root ? (
|
||||||
<div className="w-full min-h-full flex items-center justify-center p-6">
|
<div className="w-full min-h-full flex items-center justify-center p-6">
|
||||||
@@ -984,20 +1188,18 @@ ${jsx}
|
|||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between items-center mt-2">
|
<div className="flex justify-between items-center mt-2">
|
||||||
{versions.length > 0 ? (
|
<PlaygroundControls
|
||||||
<button
|
format={format}
|
||||||
onClick={() => {
|
setFormat={setFormat}
|
||||||
setVersions([]);
|
editModes={editModes}
|
||||||
setSelectedVersionId(null);
|
setEditModes={setEditModes}
|
||||||
clear();
|
showClear={versions.length > 0}
|
||||||
}}
|
onClear={() => {
|
||||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
setVersions([]);
|
||||||
>
|
setSelectedVersionId(null);
|
||||||
Clear
|
clear();
|
||||||
</button>
|
}}
|
||||||
) : (
|
/>
|
||||||
<div />
|
|
||||||
)}
|
|
||||||
{isStreaming ? (
|
{isStreaming ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => clear()}
|
onClick={() => clear()}
|
||||||
@@ -1074,9 +1276,16 @@ ${jsx}
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{version.usage && (
|
{version.usage && (
|
||||||
<div className="flex items-center gap-2 mt-1 ml-6">
|
<div className="mt-1 ml-6">
|
||||||
<span className="text-[10px] font-mono text-muted-foreground/60">
|
<span className="text-[10px] font-mono text-muted-foreground/60">
|
||||||
{version.usage.totalTokens.toLocaleString()} tokens
|
{formatTokens(
|
||||||
|
version.usage.promptTokens -
|
||||||
|
version.usage.cachedTokens,
|
||||||
|
)}{" "}
|
||||||
|
in · {formatTokens(version.usage.completionTokens)} out
|
||||||
|
{version.usage.cachedTokens > 0
|
||||||
|
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const docsNavigation: NavSection[] = [
|
|||||||
{ title: "Introduction", href: "/docs" },
|
{ title: "Introduction", href: "/docs" },
|
||||||
{ title: "Installation", href: "/docs/installation" },
|
{ title: "Installation", href: "/docs/installation" },
|
||||||
{ title: "Quick Start", href: "/docs/quick-start" },
|
{ title: "Quick Start", href: "/docs/quick-start" },
|
||||||
|
{ title: "Skills", href: "/docs/skills" },
|
||||||
{ title: "Migration Guide", href: "/docs/migration" },
|
{ title: "Migration Guide", href: "/docs/migration" },
|
||||||
{ title: "Changelog", href: "/docs/changelog" },
|
{ title: "Changelog", href: "/docs/changelog" },
|
||||||
],
|
],
|
||||||
@@ -27,13 +28,16 @@ export const docsNavigation: NavSection[] = [
|
|||||||
{ title: "Schemas", href: "/docs/schemas" },
|
{ title: "Schemas", href: "/docs/schemas" },
|
||||||
{ title: "Catalog", href: "/docs/catalog" },
|
{ title: "Catalog", href: "/docs/catalog" },
|
||||||
{ title: "Data Binding", href: "/docs/data-binding" },
|
{ title: "Data Binding", href: "/docs/data-binding" },
|
||||||
|
{ title: "Computed Values", href: "/docs/computed-values" },
|
||||||
{ title: "Visibility", href: "/docs/visibility" },
|
{ title: "Visibility", href: "/docs/visibility" },
|
||||||
|
{ title: "Watchers", href: "/docs/watchers" },
|
||||||
{ title: "Validation", href: "/docs/validation" },
|
{ title: "Validation", href: "/docs/validation" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Rendering",
|
title: "Rendering",
|
||||||
items: [
|
items: [
|
||||||
|
{ title: "Renderers", href: "/docs/renderers" },
|
||||||
{ title: "Registry", href: "/docs/registry" },
|
{ title: "Registry", href: "/docs/registry" },
|
||||||
{ title: "Streaming", href: "/docs/streaming" },
|
{ title: "Streaming", href: "/docs/streaming" },
|
||||||
{ title: "Generation Modes", href: "/docs/generation-modes" },
|
{ title: "Generation Modes", href: "/docs/generation-modes" },
|
||||||
@@ -57,11 +61,46 @@ export const docsNavigation: NavSection[] = [
|
|||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "React PDF",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "React Email",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-email",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Remotion",
|
title: "Remotion",
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Svelte",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/svelte",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vue",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Renders with Vite (Vue / React / Svelte / Solid)",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vite-renderers",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "MCP App",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/mcp",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,10 +125,26 @@ export const docsNavigation: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||||
|
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||||
|
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
|
||||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||||
|
{ title: "@json-render/image", href: "/docs/api/image" },
|
||||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||||
|
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||||
|
{ title: "@json-render/svelte", href: "/docs/api/svelte" },
|
||||||
|
{ title: "@json-render/solid", href: "/docs/api/solid" },
|
||||||
|
{
|
||||||
|
title: "@json-render/react-three-fiber",
|
||||||
|
href: "/docs/api/react-three-fiber",
|
||||||
|
},
|
||||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||||
|
{ title: "@json-render/mcp", href: "/docs/api/mcp" },
|
||||||
|
{ title: "@json-render/redux", href: "/docs/api/redux" },
|
||||||
|
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
|
||||||
|
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
|
||||||
|
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
|
||||||
|
{ title: "@json-render/yaml", href: "/docs/api/yaml" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { PAGE_TITLES } from "./page-titles";
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
|
||||||
|
|
||||||
|
export function pageMetadata(slug: string): Metadata {
|
||||||
|
const title = PAGE_TITLES[slug];
|
||||||
|
if (!title) return {};
|
||||||
|
|
||||||
|
const displayTitle = title.replace(/\n/g, " ");
|
||||||
|
const fullTitle = `${displayTitle} | json-render`;
|
||||||
|
const ogImageUrl = slug ? `/og/${slug}` : "/og";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: displayTitle,
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
siteName: "json-render",
|
||||||
|
title: fullTitle,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: ogImageUrl,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: `${displayTitle} - json-render`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: fullTitle,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
images: [ogImageUrl],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -23,7 +23,10 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
"docs/streaming": "Streaming",
|
"docs/streaming": "Streaming",
|
||||||
"docs/validation": "Validation",
|
"docs/validation": "Validation",
|
||||||
"docs/data-binding": "Data Binding",
|
"docs/data-binding": "Data Binding",
|
||||||
|
"docs/computed-values": "Computed Values",
|
||||||
"docs/visibility": "Visibility",
|
"docs/visibility": "Visibility",
|
||||||
|
"docs/watchers": "Watchers",
|
||||||
|
"docs/renderers": "Renderers",
|
||||||
"docs/generation-modes": "Generation Modes",
|
"docs/generation-modes": "Generation Modes",
|
||||||
"docs/code-export": "Code Export",
|
"docs/code-export": "Code Export",
|
||||||
"docs/custom-schema": "Custom Schema & Renderer",
|
"docs/custom-schema": "Custom Schema & Renderer",
|
||||||
@@ -34,13 +37,28 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
"docs/ag-ui": "AG-UI Integration",
|
"docs/ag-ui": "AG-UI Integration",
|
||||||
"docs/migration": "Migration Guide",
|
"docs/migration": "Migration Guide",
|
||||||
"docs/changelog": "Changelog",
|
"docs/changelog": "Changelog",
|
||||||
|
"docs/skills": "Skills",
|
||||||
|
|
||||||
// API references
|
// API references
|
||||||
"docs/api/core": "@json-render/core API",
|
"docs/api/core": "@json-render/core API",
|
||||||
"docs/api/react": "@json-render/react API",
|
"docs/api/react": "@json-render/react API",
|
||||||
|
"docs/api/vue": "@json-render/vue API",
|
||||||
|
"docs/api/solid": "@json-render/solid API",
|
||||||
|
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||||
|
"docs/api/react-email": "@json-render/react-email API",
|
||||||
"docs/api/react-native": "@json-render/react-native API",
|
"docs/api/react-native": "@json-render/react-native API",
|
||||||
|
"docs/api/svelte": "@json-render/svelte API",
|
||||||
"docs/api/codegen": "@json-render/codegen API",
|
"docs/api/codegen": "@json-render/codegen API",
|
||||||
|
"docs/api/image": "@json-render/image API",
|
||||||
"docs/api/remotion": "@json-render/remotion API",
|
"docs/api/remotion": "@json-render/remotion API",
|
||||||
|
"docs/api/shadcn": "@json-render/shadcn API",
|
||||||
|
"docs/api/mcp": "@json-render/mcp API",
|
||||||
|
"docs/api/redux": "@json-render/redux API",
|
||||||
|
"docs/api/zustand": "@json-render/zustand API",
|
||||||
|
"docs/api/jotai": "@json-render/jotai API",
|
||||||
|
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
|
||||||
|
"docs/api/xstate": "@json-render/xstate API",
|
||||||
|
"docs/api/yaml": "@json-render/yaml API",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import type { Spec, JsonPatch } from "@json-render/core";
|
||||||
|
import { setByPath, getByPath, removeByPath } from "@json-render/core";
|
||||||
|
|
||||||
|
export function setSpecValue(
|
||||||
|
newSpec: Spec,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
if (path === "/root") {
|
||||||
|
newSpec.root = value as string;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path === "/state") {
|
||||||
|
newSpec.state = value as Record<string, unknown>;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/state/")) {
|
||||||
|
if (!newSpec.state) newSpec.state = {};
|
||||||
|
setByPath(
|
||||||
|
newSpec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
if (value == null || typeof value !== "object") return;
|
||||||
|
const el = value as Record<string, unknown>;
|
||||||
|
newSpec.elements[elementKey] = {
|
||||||
|
...el,
|
||||||
|
type: typeof el.type === "string" ? el.type : "",
|
||||||
|
props: el.props != null && typeof el.props === "object" ? el.props : {},
|
||||||
|
children: Array.isArray(el.children) ? el.children : [],
|
||||||
|
} as Spec["elements"][string];
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const newElement = { ...element };
|
||||||
|
setByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
"/" + pathParts.slice(1).join("/"),
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSpecValue(newSpec: Spec, path: string): void {
|
||||||
|
if (path === "/state") {
|
||||||
|
delete newSpec.state;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/state/") && newSpec.state) {
|
||||||
|
removeByPath(
|
||||||
|
newSpec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
delete newSpec.elements[elementKey];
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const newElement = { ...element };
|
||||||
|
removeByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
"/" + pathParts.slice(1).join("/"),
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpecValue(spec: Spec, path: string): unknown {
|
||||||
|
if (path === "/root") return spec.root;
|
||||||
|
if (path === "/state") return spec.state;
|
||||||
|
if (path.startsWith("/state/") && spec.state) {
|
||||||
|
return getByPath(
|
||||||
|
spec.state as Record<string, unknown>,
|
||||||
|
path.slice("/state".length),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getByPath(spec as unknown as Record<string, unknown>, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSpec(spec: Spec): void {
|
||||||
|
if (
|
||||||
|
spec.state === null ||
|
||||||
|
(spec.state !== undefined && typeof spec.state !== "object")
|
||||||
|
) {
|
||||||
|
spec.state = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(spec.elements)) {
|
||||||
|
const el = spec.elements[key];
|
||||||
|
if (!el || typeof el !== "object") {
|
||||||
|
delete spec.elements[key];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (el.props == null || typeof el.props !== "object") {
|
||||||
|
spec.elements[key] = { ...el, props: {} } as Spec["elements"][string];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(spec.elements[key]!.children)) {
|
||||||
|
spec.elements[key] = {
|
||||||
|
...spec.elements[key]!,
|
||||||
|
children: [],
|
||||||
|
} as Spec["elements"][string];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applySpecPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||||
|
const newSpec = {
|
||||||
|
...spec,
|
||||||
|
elements: { ...spec.elements },
|
||||||
|
...(spec.state ? { state: { ...spec.state } } : {}),
|
||||||
|
};
|
||||||
|
switch (patch.op) {
|
||||||
|
case "add":
|
||||||
|
case "replace":
|
||||||
|
setSpecValue(newSpec, patch.path, patch.value);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
removeSpecValue(newSpec, patch.path);
|
||||||
|
break;
|
||||||
|
case "move":
|
||||||
|
if (patch.from) {
|
||||||
|
const moveValue = getSpecValue(newSpec, patch.from);
|
||||||
|
removeSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, moveValue);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "copy":
|
||||||
|
if (patch.from) {
|
||||||
|
setSpecValue(newSpec, patch.path, getSpecValue(newSpec, patch.from));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "test":
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
normalizeSpec(newSpec);
|
||||||
|
return newSpec;
|
||||||
|
}
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import type { Spec, JsonPatch, EditMode } from "@json-render/core";
|
||||||
|
import { deepMergeSpec, diffToPatches } from "@json-render/core";
|
||||||
|
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
||||||
|
import { applyPatch as applyUnifiedDiff } from "diff";
|
||||||
|
import {
|
||||||
|
createYamlStreamCompiler,
|
||||||
|
YAML_SPEC_FENCE,
|
||||||
|
YAML_EDIT_FENCE,
|
||||||
|
YAML_PATCH_FENCE,
|
||||||
|
DIFF_FENCE,
|
||||||
|
FENCE_CLOSE,
|
||||||
|
} from "@json-render/yaml";
|
||||||
|
import { applySpecPatch } from "./spec-patch";
|
||||||
|
|
||||||
|
export type StreamFormat = "jsonl" | "yaml";
|
||||||
|
|
||||||
|
export interface TokenUsage {
|
||||||
|
promptTokens: number;
|
||||||
|
completionTokens: number;
|
||||||
|
totalTokens: number;
|
||||||
|
cachedTokens: number;
|
||||||
|
cacheWriteTokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePlaygroundStreamOptions {
|
||||||
|
api: string;
|
||||||
|
format: StreamFormat;
|
||||||
|
editModes?: EditMode[];
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
onComplete?: (spec: Spec) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UsePlaygroundStreamReturn {
|
||||||
|
spec: Spec | null;
|
||||||
|
isStreaming: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
usage: TokenUsage | null;
|
||||||
|
rawLines: string[];
|
||||||
|
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
|
||||||
|
clear: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── JSONL helpers ──
|
||||||
|
|
||||||
|
type ParsedLine =
|
||||||
|
| { type: "patch"; patch: JsonPatch }
|
||||||
|
| { type: "usage"; usage: TokenUsage }
|
||||||
|
| { type: "json-edit"; mergeObj: Record<string, unknown> }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
function parseLine(line: string): ParsedLine {
|
||||||
|
try {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith("//")) return null;
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
return {
|
||||||
|
type: "usage",
|
||||||
|
usage: {
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (parsed.__json_edit === true) {
|
||||||
|
const mergeObj = { ...parsed };
|
||||||
|
delete mergeObj.__json_edit;
|
||||||
|
return { type: "json-edit", mergeObj };
|
||||||
|
}
|
||||||
|
return { type: "patch", patch: parsed as JsonPatch };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
|
||||||
|
|
||||||
|
// ── Hook ──
|
||||||
|
|
||||||
|
export function usePlaygroundStream({
|
||||||
|
api,
|
||||||
|
format,
|
||||||
|
editModes,
|
||||||
|
onError,
|
||||||
|
onComplete,
|
||||||
|
}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
|
||||||
|
const [spec, setSpec] = useState<Spec | null>(null);
|
||||||
|
const [isStreaming, setIsStreaming] = useState(false);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
const [usage, setUsage] = useState<TokenUsage | null>(null);
|
||||||
|
const [rawLines, setRawLines] = useState<string[]>([]);
|
||||||
|
const rawLinesRef = useRef<string[]>([]);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const onCompleteRef = useRef(onComplete);
|
||||||
|
onCompleteRef.current = onComplete;
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
onErrorRef.current = onError;
|
||||||
|
const formatRef = useRef(format);
|
||||||
|
formatRef.current = format;
|
||||||
|
const editModesRef = useRef(editModes);
|
||||||
|
editModesRef.current = editModes;
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
setSpec(null);
|
||||||
|
setError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const send = useCallback(
|
||||||
|
async (prompt: string, context?: Record<string, unknown>) => {
|
||||||
|
abortControllerRef.current = new AbortController();
|
||||||
|
|
||||||
|
setIsStreaming(true);
|
||||||
|
setError(null);
|
||||||
|
setUsage(null);
|
||||||
|
rawLinesRef.current = [];
|
||||||
|
setRawLines([]);
|
||||||
|
|
||||||
|
const previousSpec = context?.previousSpec as Spec | undefined;
|
||||||
|
let currentSpec: Spec =
|
||||||
|
previousSpec && previousSpec.root
|
||||||
|
? { ...previousSpec, elements: { ...previousSpec.elements } }
|
||||||
|
: { root: "", elements: {} };
|
||||||
|
setSpec(currentSpec);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(api, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
prompt,
|
||||||
|
context,
|
||||||
|
format: formatRef.current,
|
||||||
|
editModes: editModesRef.current,
|
||||||
|
}),
|
||||||
|
signal: abortControllerRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorMessage = `HTTP error: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.message) errorMessage = errorData.message;
|
||||||
|
else if (errorData.error) errorMessage = errorData.error;
|
||||||
|
} catch {
|
||||||
|
// use default
|
||||||
|
}
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) throw new Error("No response body");
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
if (formatRef.current === "yaml") {
|
||||||
|
// ── YAML streaming ──
|
||||||
|
let fenceState: FenceState = "outside";
|
||||||
|
const compiler = createYamlStreamCompiler<Record<string, unknown>>();
|
||||||
|
let yamlEditAccumulated = "";
|
||||||
|
let yamlPatchAccumulated = "";
|
||||||
|
let diffAccumulated = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
// Check for usage metadata (appended after stream)
|
||||||
|
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
setUsage({
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawLinesRef.current.push(line);
|
||||||
|
|
||||||
|
if (fenceState === "outside") {
|
||||||
|
if (
|
||||||
|
trimmed === YAML_SPEC_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_SPEC_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-spec";
|
||||||
|
compiler.reset();
|
||||||
|
} else if (
|
||||||
|
trimmed === YAML_EDIT_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_EDIT_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-edit";
|
||||||
|
yamlEditAccumulated = "";
|
||||||
|
} else if (
|
||||||
|
trimmed === YAML_PATCH_FENCE ||
|
||||||
|
trimmed.startsWith(YAML_PATCH_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "yaml-patch";
|
||||||
|
yamlPatchAccumulated = "";
|
||||||
|
} else if (
|
||||||
|
trimmed === DIFF_FENCE ||
|
||||||
|
trimmed.startsWith(DIFF_FENCE + " ")
|
||||||
|
) {
|
||||||
|
fenceState = "diff";
|
||||||
|
diffAccumulated = "";
|
||||||
|
}
|
||||||
|
} else if (trimmed === FENCE_CLOSE || trimmed === "````") {
|
||||||
|
if (fenceState === "yaml-spec") {
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
if (result && typeof result === "object" && result.root) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-edit") {
|
||||||
|
try {
|
||||||
|
const editObj = yamlParse(yamlEditAccumulated);
|
||||||
|
if (editObj && typeof editObj === "object") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
editObj as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid YAML edit
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-patch") {
|
||||||
|
for (const patchLine of yamlPatchAccumulated.split("\n")) {
|
||||||
|
const t = patchLine.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
try {
|
||||||
|
const patch = JSON.parse(t) as JsonPatch;
|
||||||
|
if (patch.op) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip invalid JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else if (fenceState === "diff") {
|
||||||
|
try {
|
||||||
|
const specYaml = yamlStringify(currentSpec, { indent: 2 });
|
||||||
|
const patched = applyUnifiedDiff(specYaml, diffAccumulated);
|
||||||
|
if (typeof patched === "string") {
|
||||||
|
const parsed = yamlParse(patched);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
parsed as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff apply or reparse failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fenceState = "outside";
|
||||||
|
} else if (fenceState === "yaml-spec") {
|
||||||
|
const { newPatches } = compiler.push(line + "\n");
|
||||||
|
if (newPatches.length > 0) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-edit") {
|
||||||
|
yamlEditAccumulated += line + "\n";
|
||||||
|
} else if (fenceState === "yaml-patch") {
|
||||||
|
yamlPatchAccumulated += line + "\n";
|
||||||
|
} else if (fenceState === "diff") {
|
||||||
|
diffAccumulated += line + "\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRawLines([...rawLinesRef.current]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process remaining buffer
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const trimmed = buffer.trim();
|
||||||
|
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed);
|
||||||
|
if (parsed.__meta === "usage") {
|
||||||
|
setUsage({
|
||||||
|
promptTokens: parsed.promptTokens ?? 0,
|
||||||
|
completionTokens: parsed.completionTokens ?? 0,
|
||||||
|
totalTokens: parsed.totalTokens ?? 0,
|
||||||
|
cachedTokens: parsed.cachedTokens ?? 0,
|
||||||
|
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON
|
||||||
|
}
|
||||||
|
} else if (fenceState === "yaml-spec") {
|
||||||
|
compiler.push(buffer);
|
||||||
|
const { result, newPatches } = compiler.flush();
|
||||||
|
if (result && typeof result === "object" && result.root) {
|
||||||
|
for (const patch of newPatches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// ── JSONL streaming ──
|
||||||
|
let jsonlDiffState: "outside" | "diff" = "outside";
|
||||||
|
let jsonlDiffAccumulated = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
|
||||||
|
// Diff fence detection within JSONL mode
|
||||||
|
if (jsonlDiffState === "outside") {
|
||||||
|
if (
|
||||||
|
trimmed === DIFF_FENCE ||
|
||||||
|
trimmed.startsWith(DIFF_FENCE + " ")
|
||||||
|
) {
|
||||||
|
jsonlDiffState = "diff";
|
||||||
|
jsonlDiffAccumulated = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
jsonlDiffState === "diff" &&
|
||||||
|
(trimmed === FENCE_CLOSE || trimmed === "````")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const specJson = JSON.stringify(currentSpec, null, 2);
|
||||||
|
const patched = applyUnifiedDiff(
|
||||||
|
specJson,
|
||||||
|
jsonlDiffAccumulated,
|
||||||
|
);
|
||||||
|
if (typeof patched === "string") {
|
||||||
|
const parsed = JSON.parse(patched);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
parsed as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Diff apply failed
|
||||||
|
}
|
||||||
|
jsonlDiffState = "outside";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonlDiffState === "diff") {
|
||||||
|
jsonlDiffAccumulated += line + "\n";
|
||||||
|
rawLinesRef.current.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard JSONL line parsing
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (!result) continue;
|
||||||
|
if (result.type === "usage") {
|
||||||
|
setUsage(result.usage);
|
||||||
|
} else if (result.type === "json-edit") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
result.mergeObj,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else {
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
currentSpec = applySpecPatch(currentSpec, result.patch);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setRawLines([...rawLinesRef.current]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.trim()) {
|
||||||
|
const trimmed = buffer.trim();
|
||||||
|
const result = parseLine(trimmed);
|
||||||
|
if (result) {
|
||||||
|
if (result.type === "usage") {
|
||||||
|
setUsage(result.usage);
|
||||||
|
} else if (result.type === "json-edit") {
|
||||||
|
const merged = deepMergeSpec(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
result.mergeObj,
|
||||||
|
);
|
||||||
|
const patches = diffToPatches(
|
||||||
|
currentSpec as unknown as Record<string, unknown>,
|
||||||
|
merged,
|
||||||
|
);
|
||||||
|
for (const patch of patches) {
|
||||||
|
currentSpec = applySpecPatch(currentSpec, patch);
|
||||||
|
}
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
} else {
|
||||||
|
rawLinesRef.current.push(trimmed);
|
||||||
|
currentSpec = applySpecPatch(currentSpec, result.patch);
|
||||||
|
setSpec({ ...currentSpec });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onCompleteRef.current?.(currentSpec);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
|
const error = err instanceof Error ? err : new Error(String(err));
|
||||||
|
setError(error);
|
||||||
|
onErrorRef.current?.(error);
|
||||||
|
} finally {
|
||||||
|
setIsStreaming(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortControllerRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { spec, isStreaming, error, usage, rawLines, send, clear };
|
||||||
|
}
|
||||||
+11
-6
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||||
|
"dev": "portless json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"@json-render/codegen": "workspace:*",
|
"@json-render/codegen": "workspace:*",
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
"@json-render/react": "workspace:*",
|
"@json-render/react": "workspace:*",
|
||||||
|
"@json-render/yaml": "workspace:*",
|
||||||
"@mdx-js/loader": "^3.1.1",
|
"@mdx-js/loader": "^3.1.1",
|
||||||
"@mdx-js/mdx": "^3.1.1",
|
"@mdx-js/mdx": "^3.1.1",
|
||||||
"@mdx-js/react": "^3.1.1",
|
"@mdx-js/react": "^3.1.1",
|
||||||
@@ -28,12 +30,14 @@
|
|||||||
"@upstash/redis": "^1.36.1",
|
"@upstash/redis": "^1.36.1",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
|
"@visual-json/react": "0.1.1",
|
||||||
"ai": "^6.0.33",
|
"ai": "^6.0.33",
|
||||||
"bash-tool": "1.3.14",
|
"bash-tool": "1.3.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"diff": "^8.0.3",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"just-bash": "2.9.6",
|
"geist": "1.7.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@@ -48,11 +52,12 @@
|
|||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"unist-util-visit": "5.1.0",
|
"unist-util-visit": "5.1.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.0"
|
"yaml": "^2.8.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/nextjs.json",
|
"extends": "@internal/typescript-config/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# example-chat
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/react@0.14.0
|
||||||
|
- @json-render/shadcn@0.14.0
|
||||||
|
|
||||||
|
## 0.1.7
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [5b32de8]
|
||||||
|
- @json-render/core@0.13.0
|
||||||
|
- @json-render/react@0.13.0
|
||||||
|
- @json-render/shadcn@0.13.0
|
||||||
|
|
||||||
|
## 0.1.6
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [54a1ecf]
|
||||||
|
- @json-render/core@0.12.1
|
||||||
|
- @json-render/react@0.12.1
|
||||||
|
- @json-render/shadcn@0.12.1
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [63c339b]
|
||||||
|
- @json-render/core@0.12.0
|
||||||
|
- @json-render/react@0.12.0
|
||||||
|
- @json-render/shadcn@0.12.0
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3f1e71e]
|
||||||
|
- @json-render/core@0.11.0
|
||||||
|
- @json-render/react@0.11.0
|
||||||
|
- @json-render/shadcn@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/shadcn@0.10.0
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [b103676]
|
||||||
|
- @json-render/react@0.9.1
|
||||||
|
- @json-render/shadcn@0.9.1
|
||||||
|
- @json-render/core@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/shadcn@0.9.0
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ When the user asks for a quiz, test, or Q&A, build an interactive experience:
|
|||||||
4. You can also add a final score section that becomes visible when all questions are submitted.
|
4. You can also add a final score section that becomes visible when all questions are submitted.
|
||||||
|
|
||||||
${explorerCatalog.prompt({
|
${explorerCatalog.prompt({
|
||||||
mode: "chat",
|
mode: "inline",
|
||||||
customRules: [
|
customRules: [
|
||||||
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
|
"NEVER use viewport height classes (min-h-screen, h-screen) — the UI renders inside a fixed-size container.",
|
||||||
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
|
"Prefer Grid with columns='2' or columns='3' for side-by-side layouts.",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "example-chat",
|
"name": "example-chat",
|
||||||
"version": "0.1.0",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||||
|
"dev": "portless chat-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -33,10 +34,10 @@
|
|||||||
"streamdown": "^2.2.0",
|
"streamdown": "^2.2.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"zod": "4.3.5"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# example-dashboard
|
||||||
|
|
||||||
|
## 0.1.8
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@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
|
||||||
@@ -196,24 +196,28 @@ export function Widget({
|
|||||||
onDeleted?.();
|
onDeleted?.();
|
||||||
}, [widgetId, onDeleted]);
|
}, [widgetId, onDeleted]);
|
||||||
|
|
||||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
const handleStateChange = useCallback(
|
||||||
setState((prev) => {
|
(changes: Array<{ path: string; value: unknown }>) => {
|
||||||
const next = { ...prev };
|
setState((prev) => {
|
||||||
// Convert path like "customerForm/name" to nested object
|
const next = { ...prev };
|
||||||
const parts = path.split("/");
|
for (const { path, value } of changes) {
|
||||||
let current: Record<string, unknown> = next;
|
const parts = path.split("/");
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
let current: Record<string, unknown> = next;
|
||||||
const part = parts[i]!;
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
if (!(part in current) || typeof current[part] !== "object") {
|
const part = parts[i]!;
|
||||||
current[part] = {};
|
if (!(part in current) || typeof current[part] !== "object") {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
current = current[part] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const lastPart = parts[parts.length - 1]!;
|
||||||
|
current[lastPart] = value;
|
||||||
}
|
}
|
||||||
current = current[part] as Record<string, unknown>;
|
return next;
|
||||||
}
|
});
|
||||||
const lastPart = parts[parts.length - 1]!;
|
},
|
||||||
current[lastPart] = value;
|
[],
|
||||||
return next;
|
);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use spec from stream, or initial spec for saved widgets
|
// Use spec from stream, or initial spec for saved widgets
|
||||||
const currentSpec = spec || initialSpec;
|
const currentSpec = spec || initialSpec;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
|
|||||||
spec: Spec | null;
|
spec: Spec | null;
|
||||||
state?: Record<string, unknown>;
|
state?: Record<string, unknown>;
|
||||||
setState?: SetState;
|
setState?: SetState;
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "example-dashboard",
|
"name": "example-dashboard",
|
||||||
"version": "0.1.0",
|
"version": "0.1.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||||
|
"dev": "portless dashboard-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -39,10 +40,10 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# example-image
|
||||||
|
|
||||||
|
## 0.1.5
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [a8afd8b]
|
||||||
|
- @json-render/core@0.14.0
|
||||||
|
- @json-render/image@0.14.0
|
||||||
|
|
||||||
|
## 0.1.4
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [5b32de8]
|
||||||
|
- @json-render/core@0.13.0
|
||||||
|
- @json-render/image@0.13.0
|
||||||
|
|
||||||
|
## 0.1.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [54a1ecf]
|
||||||
|
- @json-render/core@0.12.1
|
||||||
|
- @json-render/image@0.12.1
|
||||||
|
|
||||||
|
## 0.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [63c339b]
|
||||||
|
- @json-render/core@0.12.0
|
||||||
|
- @json-render/image@0.12.0
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [3f1e71e]
|
||||||
|
- @json-render/core@0.11.0
|
||||||
|
- @json-render/image@0.11.0
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { streamText } from "ai";
|
||||||
|
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||||
|
import { imageCatalog } from "@/lib/catalog";
|
||||||
|
|
||||||
|
export const maxDuration = 60;
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = imageCatalog.prompt();
|
||||||
|
|
||||||
|
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { prompt, startingSpec } = (await req.json()) as {
|
||||||
|
prompt: string;
|
||||||
|
startingSpec?: Spec | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!prompt || typeof prompt !== "string") {
|
||||||
|
return Response.json({ error: "prompt is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userPrompt = buildUserPrompt({
|
||||||
|
prompt,
|
||||||
|
currentSpec: startingSpec,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
prompt: userPrompt,
|
||||||
|
temperature: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.toTextStreamResponse();
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { renderToPng } from "@json-render/image/render";
|
||||||
|
import { examples } from "@/lib/examples";
|
||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
let fontCache: ArrayBuffer | null = null;
|
||||||
|
|
||||||
|
async function loadFont(): Promise<ArrayBuffer> {
|
||||||
|
if (fontCache) return fontCache;
|
||||||
|
const fontPath = join(
|
||||||
|
process.cwd(),
|
||||||
|
"node_modules",
|
||||||
|
"geist",
|
||||||
|
"dist",
|
||||||
|
"fonts",
|
||||||
|
"geist-sans",
|
||||||
|
"Geist-Regular.ttf",
|
||||||
|
);
|
||||||
|
const buffer = await readFile(fontPath);
|
||||||
|
fontCache = buffer.buffer.slice(
|
||||||
|
buffer.byteOffset,
|
||||||
|
buffer.byteOffset + buffer.byteLength,
|
||||||
|
);
|
||||||
|
return fontCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const name = searchParams.get("name") ?? "og-image";
|
||||||
|
const download = searchParams.get("download") === "1";
|
||||||
|
|
||||||
|
const example = examples.find((e) => e.name === name);
|
||||||
|
if (!example) {
|
||||||
|
return new Response("Example not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageResponse(example.spec, name, download);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const { spec, download, filename } = (await req.json()) as {
|
||||||
|
spec: Spec;
|
||||||
|
download?: boolean;
|
||||||
|
filename?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!spec || !spec.root || !spec.elements) {
|
||||||
|
return new Response("Invalid spec", { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return imageResponse(spec, filename ?? "image", download ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imageResponse(spec: Spec, name: string, download: boolean) {
|
||||||
|
const fontData = await loadFont();
|
||||||
|
const fonts = [
|
||||||
|
{
|
||||||
|
name: "Geist Sans",
|
||||||
|
data: fontData,
|
||||||
|
weight: 400 as const,
|
||||||
|
style: "normal" as const,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const png = await renderToPng(spec, { fonts });
|
||||||
|
|
||||||
|
const disposition = download
|
||||||
|
? `attachment; filename="${name}.png"`
|
||||||
|
: `inline; filename="${name}.png"`;
|
||||||
|
|
||||||
|
return new Response(Buffer.from(png), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "image/png",
|
||||||
|
"Content-Disposition": disposition,
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "shadcn/tailwind.css";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
--radius-3xl: calc(var(--radius) + 12px);
|
||||||
|
--radius-4xl: calc(var(--radius) + 16px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geist = Geist({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "json-render Image Example",
|
||||||
|
description: "Generate images from JSON specs with @json-render/image",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={geist.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,627 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
|
import { examples } from "@/lib/examples";
|
||||||
|
import { createSpecStreamCompiler } from "@json-render/core";
|
||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
ResizablePanelGroup,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizableHandle,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import { ImageIcon, Download, Loader2, ArrowRight, Square } from "lucide-react";
|
||||||
|
|
||||||
|
type Mode = "scratch" | "example";
|
||||||
|
type MobileView = "json" | "preview";
|
||||||
|
|
||||||
|
interface Selection {
|
||||||
|
mode: Mode;
|
||||||
|
exampleName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_REFRESH_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
function CopyButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono"
|
||||||
|
>
|
||||||
|
{copied ? "copied" : "copy"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRenderableSpec(spec: Spec | null): spec is Spec {
|
||||||
|
if (!spec?.root || !spec.elements) return false;
|
||||||
|
const root = spec.elements[spec.root];
|
||||||
|
return root?.type === "Frame";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [selection, setSelection] = useState<Selection>({
|
||||||
|
mode: "example",
|
||||||
|
exampleName: examples[0]!.name,
|
||||||
|
});
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [generatedSpec, setGeneratedSpec] = useState<Spec | null>(null);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [mobileView, setMobileView] = useState<MobileView>("preview");
|
||||||
|
const [examplesSheetOpen, setExamplesSheetOpen] = useState(false);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const imageUrlRef = useRef<string | null>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const codeScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!generating) return;
|
||||||
|
codeScrollRef.current?.scrollTo({
|
||||||
|
top: codeScrollRef.current.scrollHeight,
|
||||||
|
});
|
||||||
|
mobileCodeScrollRef.current?.scrollTo({
|
||||||
|
top: mobileCodeScrollRef.current.scrollHeight,
|
||||||
|
});
|
||||||
|
}, [generating, generatedSpec]);
|
||||||
|
|
||||||
|
const currentExample =
|
||||||
|
selection.mode === "example"
|
||||||
|
? examples.find((e) => e.name === selection.exampleName)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
|
||||||
|
|
||||||
|
const exampleImageUrl =
|
||||||
|
selection.mode === "example" && !generatedSpec
|
||||||
|
? `/api/image?name=${selection.exampleName}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const displayImageUrl = imageUrl ?? exampleImageUrl;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [selection.mode, selection.exampleName]);
|
||||||
|
|
||||||
|
const fetchImageBlob = useCallback(
|
||||||
|
async (spec: Spec, signal?: AbortSignal) => {
|
||||||
|
const res = await fetch("/api/image", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ spec }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to generate image");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const prev = imageUrlRef.current;
|
||||||
|
imageUrlRef.current = url;
|
||||||
|
setImageUrl(url);
|
||||||
|
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastRefreshSpec = useRef<string>("");
|
||||||
|
const generatedSpecRef = useRef<Spec | null>(null);
|
||||||
|
generatedSpecRef.current = generatedSpec;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!generating) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const spec = generatedSpecRef.current;
|
||||||
|
if (!spec) return;
|
||||||
|
|
||||||
|
const specKey = JSON.stringify(spec);
|
||||||
|
if (specKey === lastRefreshSpec.current) return;
|
||||||
|
if (!isRenderableSpec(spec)) return;
|
||||||
|
|
||||||
|
lastRefreshSpec.current = specKey;
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchImageBlob(spec)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setRefreshing(false));
|
||||||
|
}, IMAGE_REFRESH_INTERVAL_MS);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [generating, fetchImageBlob]);
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
setGenerating(true);
|
||||||
|
setError(null);
|
||||||
|
lastRefreshSpec.current = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startingSpec =
|
||||||
|
selection.mode === "example" && currentExample
|
||||||
|
? currentExample.spec
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const res = await fetch("/api/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ prompt: prompt.trim(), startingSpec }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Generation failed");
|
||||||
|
|
||||||
|
const reader = res.body?.getReader();
|
||||||
|
if (!reader) throw new Error("No response body");
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
const compiler = createSpecStreamCompiler<Spec>(
|
||||||
|
startingSpec ? { ...startingSpec } : {},
|
||||||
|
);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const { result, newPatches } = compiler.push(chunk);
|
||||||
|
if (newPatches.length > 0) setGeneratedSpec(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalSpec = compiler.getResult();
|
||||||
|
setGeneratedSpec(finalSpec);
|
||||||
|
setGenerating(false);
|
||||||
|
|
||||||
|
await fetchImageBlob(finalSpec);
|
||||||
|
} catch (e) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}, [prompt, selection, currentExample, fetchImageBlob]);
|
||||||
|
|
||||||
|
const handleStop = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setGenerating(false);
|
||||||
|
|
||||||
|
if (isRenderableSpec(generatedSpec)) {
|
||||||
|
fetchImageBlob(generatedSpec).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [generatedSpec, fetchImageBlob]);
|
||||||
|
|
||||||
|
const select = (next: Selection) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setSelection(next);
|
||||||
|
setGeneratedSpec(null);
|
||||||
|
setImageUrl(null);
|
||||||
|
setError(null);
|
||||||
|
setPrompt("");
|
||||||
|
setGenerating(false);
|
||||||
|
setExamplesSheetOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
if (!activeSpec) return;
|
||||||
|
if (generatedSpec) {
|
||||||
|
const res = await fetch("/api/image", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ spec: generatedSpec, download: true }),
|
||||||
|
});
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "image.png";
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} else if (selection.mode === "example") {
|
||||||
|
window.open(
|
||||||
|
`/api/image?name=${selection.exampleName}&download=1`,
|
||||||
|
"_blank",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleGenerate();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleGenerate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const jsonCode = activeSpec
|
||||||
|
? JSON.stringify(activeSpec, null, 2)
|
||||||
|
: "// select an example or generate an image";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pane: Chat / Examples
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const chatPane = (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="border-b border-border px-3 h-9 flex items-center gap-2">
|
||||||
|
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-xs font-mono text-muted-foreground">
|
||||||
|
json-render / image
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||||
|
start
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => select({ mode: "scratch" })}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||||
|
selection.mode === "scratch"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">From scratch</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||||
|
examples
|
||||||
|
</p>
|
||||||
|
{examples.map((ex) => (
|
||||||
|
<button
|
||||||
|
key={ex.name}
|
||||||
|
onClick={() => select({ mode: "example", exampleName: ex.name })}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||||
|
selection.mode === "example" &&
|
||||||
|
selection.exampleName === ex.name
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{ex.label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||||
|
{ex.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="border-t border-border p-3 cursor-text"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||||
|
e.preventDefault();
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
selection.mode === "scratch"
|
||||||
|
? "Describe the image you want..."
|
||||||
|
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||||
|
}
|
||||||
|
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||||
|
rows={2}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{selection.mode === "example" && currentExample
|
||||||
|
? currentExample.label
|
||||||
|
: "scratch"}
|
||||||
|
</span>
|
||||||
|
{generating ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||||
|
aria-label="Stop"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!prompt.trim()}
|
||||||
|
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||||
|
aria-label="Generate"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pane: JSON Spec
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const codePane = (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-foreground">json</span>
|
||||||
|
{generating && (
|
||||||
|
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{activeSpec && <CopyButton text={jsonCode} />}
|
||||||
|
</div>
|
||||||
|
<div ref={codeScrollRef} className="flex-1 overflow-auto">
|
||||||
|
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||||
|
{jsonCode}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pane: Image Preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const previewPane = (
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
|
<span className="text-xs font-mono text-foreground">preview</span>
|
||||||
|
{(generating || refreshing) && (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{activeSpec && (
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors font-mono flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Download className="h-3 w-3" />
|
||||||
|
download
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 relative bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||||
|
{displayImageUrl ? (
|
||||||
|
<img
|
||||||
|
key={displayImageUrl}
|
||||||
|
src={displayImageUrl}
|
||||||
|
alt="Generated image"
|
||||||
|
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||||
|
<ImageIcon className="h-10 w-10" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{selection.mode === "scratch"
|
||||||
|
? "Enter a prompt to generate an image"
|
||||||
|
: "Select an example to preview"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Render
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div className="h-dvh flex flex-col">
|
||||||
|
{/* Desktop: 3-pane resizable layout */}
|
||||||
|
<div className="hidden lg:flex flex-1 min-h-0">
|
||||||
|
<ResizablePanelGroup className="flex-1">
|
||||||
|
<ResizablePanel defaultSize={25} minSize={15}>
|
||||||
|
{chatPane}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={35} minSize={20}>
|
||||||
|
{codePane}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle />
|
||||||
|
<ResizablePanel defaultSize={40} minSize={20}>
|
||||||
|
{previewPane}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: toolbar + content + prompt */}
|
||||||
|
<div className="flex lg:hidden flex-col flex-1 min-h-0">
|
||||||
|
<div className="border-b border-border h-10 flex items-center px-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setExamplesSheetOpen(true)}
|
||||||
|
className="px-3 h-full text-xs font-mono text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<ImageIcon className="h-3.5 w-3.5" />
|
||||||
|
{selection.mode === "example" && currentExample
|
||||||
|
? currentExample.label
|
||||||
|
: "scratch"}
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileView("json")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 h-full text-xs font-mono transition-colors",
|
||||||
|
mobileView === "json"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
json
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileView("preview")}
|
||||||
|
className={cn(
|
||||||
|
"px-3 h-full text-xs font-mono transition-colors",
|
||||||
|
mobileView === "preview"
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
preview
|
||||||
|
</button>
|
||||||
|
{activeSpec && (
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="px-3 h-full text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
{mobileView === "json" ? (
|
||||||
|
<div ref={mobileCodeScrollRef} className="h-full overflow-auto">
|
||||||
|
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||||
|
{jsonCode}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||||
|
{displayImageUrl ? (
|
||||||
|
<img
|
||||||
|
key={displayImageUrl}
|
||||||
|
src={displayImageUrl}
|
||||||
|
alt="Generated image"
|
||||||
|
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||||
|
<ImageIcon className="h-10 w-10" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{selection.mode === "scratch"
|
||||||
|
? "Enter a prompt to generate an image"
|
||||||
|
: "Select an example"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="border-t border-border p-3 cursor-text"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
|
||||||
|
e.preventDefault();
|
||||||
|
mobileInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{error && (
|
||||||
|
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={mobileInputRef}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
selection.mode === "scratch"
|
||||||
|
? "Describe the image you want..."
|
||||||
|
: `Modify the ${currentExample?.label ?? "example"}...`
|
||||||
|
}
|
||||||
|
className="w-full bg-background text-sm resize-none outline-none placeholder:text-muted-foreground/50"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
{generating ? (
|
||||||
|
<button
|
||||||
|
onClick={handleStop}
|
||||||
|
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
|
||||||
|
aria-label="Stop"
|
||||||
|
>
|
||||||
|
<Square className="h-3 w-3" fill="currentColor" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!prompt.trim()}
|
||||||
|
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
|
||||||
|
aria-label="Generate"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
|
||||||
|
<SheetContent side="left" className="w-72 p-0">
|
||||||
|
<SheetTitle className="sr-only">Examples</SheetTitle>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||||
|
start
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => select({ mode: "scratch" })}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||||
|
selection.mode === "scratch"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">From scratch</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
|
||||||
|
examples
|
||||||
|
</p>
|
||||||
|
{examples.map((ex) => (
|
||||||
|
<button
|
||||||
|
key={ex.name}
|
||||||
|
onClick={() =>
|
||||||
|
select({ mode: "example", exampleName: ex.name })
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
|
||||||
|
selection.mode === "example" &&
|
||||||
|
selection.exampleName === ex.name
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{ex.label}</span>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
|
||||||
|
{ex.description}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { GripVerticalIcon } from "lucide-react";
|
||||||
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ResizablePanelGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ResizablePrimitive.GroupProps) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Group
|
||||||
|
data-slot="resizable-panel-group"
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||||
|
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResizableHandle({
|
||||||
|
withHandle,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ResizablePrimitive.SeparatorProps & {
|
||||||
|
withHandle?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ResizablePrimitive.Separator
|
||||||
|
data-slot="resizable-handle"
|
||||||
|
className={cn(
|
||||||
|
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{withHandle && (
|
||||||
|
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||||
|
<GripVerticalIcon className="size-2.5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePrimitive.Separator>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none",
|
||||||
|
orientation === "vertical" &&
|
||||||
|
"h-full w-2.5 border-l border-l-transparent",
|
||||||
|
orientation === "horizontal" &&
|
||||||
|
"h-2.5 flex-col border-t border-t-transparent",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="bg-border relative flex-1 rounded-full"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar };
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
data-slot="sheet-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 SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left";
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content
|
||||||
|
data-slot="sheet-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||||
|
side === "right" &&
|
||||||
|
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||||
|
side === "left" &&
|
||||||
|
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||||
|
side === "top" &&
|
||||||
|
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||||
|
side === "bottom" &&
|
||||||
|
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn("text-foreground font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
|
export default [
|
||||||
|
...nextJsConfig,
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"@next/next/no-img-element": "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineCatalog } from "@json-render/core";
|
||||||
|
import { schema } from "@json-render/image/server";
|
||||||
|
import { standardComponentDefinitions } from "@json-render/image/catalog";
|
||||||
|
|
||||||
|
export const imageCatalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
});
|
||||||
@@ -0,0 +1,771 @@
|
|||||||
|
import type { Spec } from "@json-render/core";
|
||||||
|
|
||||||
|
export interface Example {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
spec: Spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const examples: Example[] = [
|
||||||
|
// ==========================================================================
|
||||||
|
// OG Image
|
||||||
|
// ==========================================================================
|
||||||
|
{
|
||||||
|
name: "og-image",
|
||||||
|
label: "OG Image",
|
||||||
|
description: "Social sharing card for Open Graph (1200x630)",
|
||||||
|
spec: {
|
||||||
|
root: "frame",
|
||||||
|
elements: {
|
||||||
|
frame: {
|
||||||
|
type: "Frame",
|
||||||
|
props: {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
backgroundColor: "#0f172a",
|
||||||
|
padding: 60,
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
children: ["top-row", "content", "bottom-row"],
|
||||||
|
},
|
||||||
|
"top-row": {
|
||||||
|
type: "Row",
|
||||||
|
props: { gap: 12, alignItems: "center" },
|
||||||
|
children: ["logo-dot", "brand"],
|
||||||
|
},
|
||||||
|
"logo-dot": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
type: "Text",
|
||||||
|
props: { text: "acme.dev", fontSize: 24, color: "#94a3b8" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 16 },
|
||||||
|
children: ["title", "subtitle"],
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: "Heading",
|
||||||
|
props: {
|
||||||
|
text: "Build faster with the modern developer platform",
|
||||||
|
level: "h1",
|
||||||
|
color: "#f8fafc",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Ship production-ready apps in minutes, not months.",
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#94a3b8",
|
||||||
|
lineHeight: 1.4,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"bottom-row": {
|
||||||
|
type: "Row",
|
||||||
|
props: { gap: 8, alignItems: "center" },
|
||||||
|
children: ["url"],
|
||||||
|
},
|
||||||
|
url: {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "acme.dev/platform",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#6366f1",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Blog Post Card
|
||||||
|
// ==========================================================================
|
||||||
|
{
|
||||||
|
name: "blog-card",
|
||||||
|
label: "Blog Post Card",
|
||||||
|
description: "Social card for a blog post with author info",
|
||||||
|
spec: {
|
||||||
|
root: "frame",
|
||||||
|
elements: {
|
||||||
|
frame: {
|
||||||
|
type: "Frame",
|
||||||
|
props: {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
padding: 60,
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
children: ["header", "body", "footer"],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
type: "Row",
|
||||||
|
props: { gap: 10, alignItems: "center" },
|
||||||
|
children: ["category-badge"],
|
||||||
|
},
|
||||||
|
"category-badge": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
backgroundColor: "#dbeafe",
|
||||||
|
borderRadius: 20,
|
||||||
|
paddingTop: 6,
|
||||||
|
paddingBottom: 6,
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16,
|
||||||
|
},
|
||||||
|
children: ["category-text"],
|
||||||
|
},
|
||||||
|
"category-text": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Engineering",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#1d4ed8",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 20 },
|
||||||
|
children: ["blog-title", "blog-excerpt"],
|
||||||
|
},
|
||||||
|
"blog-title": {
|
||||||
|
type: "Heading",
|
||||||
|
props: {
|
||||||
|
text: "How We Reduced API Latency by 90% with Edge Computing",
|
||||||
|
level: "h1",
|
||||||
|
color: "#0f172a",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.15,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"blog-excerpt": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "A deep dive into our migration from centralized servers to edge functions, and the performance gains we achieved along the way.",
|
||||||
|
fontSize: 22,
|
||||||
|
color: "#64748b",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: "Row",
|
||||||
|
props: { gap: 16, alignItems: "center" },
|
||||||
|
children: ["author-avatar", "author-info"],
|
||||||
|
},
|
||||||
|
"author-avatar": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
backgroundColor: "#e2e8f0",
|
||||||
|
borderRadius: 24,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"author-info": {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 2 },
|
||||||
|
children: ["author-name", "author-date"],
|
||||||
|
},
|
||||||
|
"author-name": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Sarah Chen",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#0f172a",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"author-date": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "February 15, 2026",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#94a3b8",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Event Banner
|
||||||
|
// ==========================================================================
|
||||||
|
{
|
||||||
|
name: "event-banner",
|
||||||
|
label: "Event Banner",
|
||||||
|
description: "Wide promotional banner for an event (1920x1080)",
|
||||||
|
spec: {
|
||||||
|
root: "frame",
|
||||||
|
elements: {
|
||||||
|
frame: {
|
||||||
|
type: "Frame",
|
||||||
|
props: {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
backgroundColor: "#18181b",
|
||||||
|
padding: 100,
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"badge-row",
|
||||||
|
"spacer-1",
|
||||||
|
"event-title",
|
||||||
|
"spacer-2",
|
||||||
|
"details-row",
|
||||||
|
"spacer-3",
|
||||||
|
"cta",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"badge-row": {
|
||||||
|
type: "Row",
|
||||||
|
props: { alignItems: "center", justifyContent: "center" },
|
||||||
|
children: ["live-badge"],
|
||||||
|
},
|
||||||
|
"live-badge": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
backgroundColor: "#dc2626",
|
||||||
|
borderRadius: 24,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingLeft: 24,
|
||||||
|
paddingRight: 24,
|
||||||
|
},
|
||||||
|
children: ["live-text"],
|
||||||
|
},
|
||||||
|
"live-text": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "LIVE EVENT",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"spacer-1": {
|
||||||
|
type: "Spacer",
|
||||||
|
props: { height: 40 },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"event-title": {
|
||||||
|
type: "Heading",
|
||||||
|
props: {
|
||||||
|
text: "DevConf 2026",
|
||||||
|
level: "h1",
|
||||||
|
color: "#ffffff",
|
||||||
|
align: "center",
|
||||||
|
letterSpacing: "-0.03em",
|
||||||
|
lineHeight: 1.0,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"spacer-2": {
|
||||||
|
type: "Spacer",
|
||||||
|
props: { height: 32 },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"details-row": {
|
||||||
|
type: "Row",
|
||||||
|
props: {
|
||||||
|
gap: 40,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
children: ["date-text", "dot-sep", "location-text"],
|
||||||
|
},
|
||||||
|
"date-text": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "March 15-17, 2026",
|
||||||
|
fontSize: 28,
|
||||||
|
color: "#a1a1aa",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"dot-sep": {
|
||||||
|
type: "Text",
|
||||||
|
props: { text: "/", fontSize: 28, color: "#52525b" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"location-text": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "San Francisco, CA",
|
||||||
|
fontSize: 28,
|
||||||
|
color: "#a1a1aa",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"spacer-3": {
|
||||||
|
type: "Spacer",
|
||||||
|
props: { height: 48 },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
cta: {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
paddingLeft: 48,
|
||||||
|
paddingRight: 48,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
children: ["cta-text"],
|
||||||
|
},
|
||||||
|
"cta-text": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Register Now",
|
||||||
|
fontSize: 24,
|
||||||
|
color: "#ffffff",
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Social Square
|
||||||
|
// ==========================================================================
|
||||||
|
{
|
||||||
|
name: "social-square",
|
||||||
|
label: "Social Square",
|
||||||
|
description: "Square format card for Instagram or social media (1080x1080)",
|
||||||
|
spec: {
|
||||||
|
root: "frame",
|
||||||
|
elements: {
|
||||||
|
frame: {
|
||||||
|
type: "Frame",
|
||||||
|
props: {
|
||||||
|
width: 1080,
|
||||||
|
height: 1080,
|
||||||
|
backgroundColor: "#faf5ff",
|
||||||
|
padding: 80,
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
children: ["top-section", "quote-section", "bottom-section"],
|
||||||
|
},
|
||||||
|
"top-section": {
|
||||||
|
type: "Row",
|
||||||
|
props: {
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
},
|
||||||
|
children: ["quote-icon", "slide-number"],
|
||||||
|
},
|
||||||
|
"quote-icon": {
|
||||||
|
type: "Heading",
|
||||||
|
props: { text: '"', level: "h1", color: "#9333ea" },
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"slide-number": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "01 / 05",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#a855f7",
|
||||||
|
fontWeight: "bold",
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"quote-section": {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 24 },
|
||||||
|
children: ["quote-text"],
|
||||||
|
},
|
||||||
|
"quote-text": {
|
||||||
|
type: "Heading",
|
||||||
|
props: {
|
||||||
|
text: "The best way to predict the future is to create it.",
|
||||||
|
level: "h2",
|
||||||
|
color: "#1e1b4b",
|
||||||
|
letterSpacing: "-0.01em",
|
||||||
|
lineHeight: 1.3,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"bottom-section": {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 4 },
|
||||||
|
children: ["author-name-sq", "author-title"],
|
||||||
|
},
|
||||||
|
"author-name-sq": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Peter Drucker",
|
||||||
|
fontSize: 22,
|
||||||
|
color: "#1e1b4b",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"author-title": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Management Consultant & Author",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#7c3aed",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Bar Graph
|
||||||
|
// ==========================================================================
|
||||||
|
{
|
||||||
|
name: "bar-graph",
|
||||||
|
label: "Bar Graph",
|
||||||
|
description: "Data visualization bar chart (1200x630)",
|
||||||
|
spec: {
|
||||||
|
root: "frame",
|
||||||
|
elements: {
|
||||||
|
frame: {
|
||||||
|
type: "Frame",
|
||||||
|
props: {
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
backgroundColor: "#ffffff",
|
||||||
|
padding: 48,
|
||||||
|
flexDirection: "column",
|
||||||
|
},
|
||||||
|
children: ["header", "chart-area"],
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
type: "Column",
|
||||||
|
props: { gap: 4 },
|
||||||
|
children: ["chart-title", "chart-subtitle"],
|
||||||
|
},
|
||||||
|
"chart-title": {
|
||||||
|
type: "Heading",
|
||||||
|
props: {
|
||||||
|
text: "Monthly Revenue",
|
||||||
|
level: "h2",
|
||||||
|
color: "#0f172a",
|
||||||
|
letterSpacing: "-0.02em",
|
||||||
|
lineHeight: 1.2,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"chart-subtitle": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Q3-Q4 2025 (in thousands USD)",
|
||||||
|
fontSize: 18,
|
||||||
|
color: "#94a3b8",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"chart-area": {
|
||||||
|
type: "Row",
|
||||||
|
props: {
|
||||||
|
gap: 0,
|
||||||
|
alignItems: "flex-end",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
"bar-jul",
|
||||||
|
"bar-aug",
|
||||||
|
"bar-sep",
|
||||||
|
"bar-oct",
|
||||||
|
"bar-nov",
|
||||||
|
"bar-dec",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-jul": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-jul", "rect-jul", "lbl-jul"],
|
||||||
|
},
|
||||||
|
"val-jul": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$42k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-jul": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 168,
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-jul": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Jul",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-aug": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-aug", "rect-aug", "lbl-aug"],
|
||||||
|
},
|
||||||
|
"val-aug": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$58k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-aug": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 232,
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-aug": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Aug",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-sep": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-sep", "rect-sep", "lbl-sep"],
|
||||||
|
},
|
||||||
|
"val-sep": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$51k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-sep": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 204,
|
||||||
|
backgroundColor: "#6366f1",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-sep": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Sep",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-oct": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-oct", "rect-oct", "lbl-oct"],
|
||||||
|
},
|
||||||
|
"val-oct": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$73k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-oct": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 292,
|
||||||
|
backgroundColor: "#818cf8",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-oct": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Oct",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-nov": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-nov", "rect-nov", "lbl-nov"],
|
||||||
|
},
|
||||||
|
"val-nov": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$85k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-nov": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 340,
|
||||||
|
backgroundColor: "#818cf8",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-nov": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Nov",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
"bar-dec": {
|
||||||
|
type: "Column",
|
||||||
|
props: {
|
||||||
|
gap: 8,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
children: ["val-dec", "rect-dec", "lbl-dec"],
|
||||||
|
},
|
||||||
|
"val-dec": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "$97k",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
fontWeight: "bold",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"rect-dec": {
|
||||||
|
type: "Box",
|
||||||
|
props: {
|
||||||
|
width: 80,
|
||||||
|
height: 388,
|
||||||
|
backgroundColor: "#4f46e5",
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
"lbl-dec": {
|
||||||
|
type: "Text",
|
||||||
|
props: {
|
||||||
|
text: "Dec",
|
||||||
|
fontSize: 16,
|
||||||
|
color: "#64748b",
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
serverExternalPackages: ["@resvg/resvg-js", "satori"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "example-image",
|
||||||
|
"version": "0.1.5",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "portless image-demo.json-render next dev --turbopack",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"check-types": "tsc --noEmit",
|
||||||
|
"lint": "eslint --max-warnings 0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@json-render/core": "workspace:*",
|
||||||
|
"@json-render/image": "workspace:*",
|
||||||
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
|
"satori": "^0.19.2",
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"ai": "6.0.103",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"geist": "^1.7.0",
|
||||||
|
"lucide-react": "^0.575.0",
|
||||||
|
"next": "16.1.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
|
"react": "19.2.4",
|
||||||
|
"react-dom": "19.2.4",
|
||||||
|
"react-resizable-panels": "^4.4.1",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/eslint-config": "workspace:*",
|
||||||
|
"@internal/typescript-config": "workspace:*",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/react": "19.2.3",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"shadcn": "^3.8.5",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user