Compare commits

..
Author SHA1 Message Date
Chris Tate 771f438677 fixes 2026-03-02 16:36:44 -06:00
Chris Tate 2005fb0aae fixes 2026-03-02 16:11:02 -06:00
Chris Tate 51b813c107 fix: update tsconfig extends from @repo to @internal/typescript-config 2026-03-02 15:57:07 -06:00
Chris Tate 0716b1075c merge latest 2026-03-02 15:52:48 -06:00
Chris Tate 6f3f07deb9 Merge remote-tracking branch 'origin/main' into feat/react-email-examples 2026-03-02 15:49:28 -06:00
Chris Tate f29b1c2ef6 adds predev for portless to vue/vite (#178) 2026-03-02 11:52:08 -06:00
Michał Czapliński 8968bd648a Add missing portless dependency (#143)
* add portless dependency to package.json and update pnpm-lock.yaml

* fix: Add a `predev` command which warns that global portless install is required.

* fix: remove portless dependency

* fix: revert changes to pnpm-lock.yml

* revert again after pulling latest changes
2026-03-02 11:43:55 -06:00
William Manzoli 5177ae8472 Merge branch 'main' into feat/react-email-examples 2026-03-01 01:16:07 -03:00
github-actions[bot] 023ca789b2 chore: version packages (#176)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-27 13:29:16 -06:00
Chris Tate 3f1e71e779 prepare v0.11.0 (#175)
* prepare v0.11.0

* release instructions
2026-02-27 13:25:55 -06:00
Chris Tate 553c803422 image (#173)
* image

* fixes

* fixes

* fix ci

* fix ci

* disable @next/next/no-img-element rule in ESLint configuration

* update route.ts to use path.join for font resolution and adjust next-env.d.ts import path
2026-02-27 13:03:42 -06:00
Chris Tate 9f58d8712c fix docs (#167)
* fix docs

* fix docs
2026-02-25 16:19:29 -06:00
William Manzoli 72174a0453 Merge branch 'main' into feat/react-email-examples 2026-02-25 12:55:11 -03:00
Lucian Fialho 39149594e0 Merge branch 'main' into feat/react-email-examples 2026-02-25 07:40:35 -03:00
Lucian Fialho c034e2dbcb fix PR review feedback
- fix tsconfig: remove rootDir, expand include for test imports
- fix render tests: add non-null assertions for noUncheckedIndexedAccess
- fix dev script: add portless to match other examples
- fix .env.example: correct comment from video to email generation
- fix docs: add blank line before heading in installation page
2026-02-25 07:36:31 -03:00
Lucian Fialho 2b86d50b57 fix @internal/react-state alias in vitest config
Pre-existing issue: vitest could not resolve @internal/react-state,
causing 6 test suites in packages/react and packages/react-pdf to fail.
2026-02-25 07:36:24 -03:00
Lucian Fialho fc841d7bb9 Merge branch 'main' into feat/react-email-examples 2026-02-24 21:52:42 -03:00
WManzoli 0770687bbd add json render react email skill 2026-02-24 21:25:49 -03:00
WManzoli 282fe6138f add react email documentation 2026-02-24 20:24:24 -03:00
Lucian Fialho 0a5cbb4529 fix missing style prop on stripe-welcome logo Image 2026-02-24 19:03:56 -03:00
Lucian Fialho 6a48ad8608 add render tests for react-email package 2026-02-24 14:42:29 -03:00
Lucian Fialho 3aa8b392dc add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt 2026-02-24 14:42:18 -03:00
Lucian Fialho 1de2b380ef add react-email package 2026-02-24 14:41:57 -03:00
Lucian Fialho 4afbb433a1 add react-email workspace dependencies 2026-02-24 14:41:39 -03:00
154 changed files with 12806 additions and 369 deletions
+3 -1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/react-email",
"@json-render/react-pdf",
"@json-render/shadcn",
"@json-render/react-native",
@@ -15,7 +16,8 @@
"@json-render/redux",
"@json-render/jotai",
"@json-render/vue",
"@json-render/xstate"
"@json-render/xstate",
"@json-render/image"
]
],
"linked": [],
+36
View File
@@ -26,6 +26,7 @@ This ensures we don't install outdated versions that may have incompatible types
- Do not use emojis in code or UI
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
- **Web app docs (`apps/web/`):** Never use Markdown table syntax (`| col | col |`). Always use HTML `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`. Markdown tables do not render correctly in the web app. Inside HTML table cells, curly braces must be escaped as JSX expressions (e.g. `<code>{'{ "$state": "/path" }'}</code>`) because MDX parses `{` as a JSX expression boundary.
## AI SDK / AI Gateway
@@ -72,6 +73,41 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
- `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/json-render-<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 -->
## Source Code Reference
+68
View File
@@ -15,6 +15,8 @@ npm install @json-render/core @json-render/react-native
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
# or for HTML email
npm install @json-render/core @json-render/react-email @react-email/components @react-email/render
# or for Vue
npm install @json-render/core @json-render/vue
```
@@ -118,6 +120,8 @@ function Dashboard({ spec }) {
| `@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/redux` | Redux / Redux Toolkit adapter for `StateStore` |
| `@json-render/zustand` | Zustand adapter for `StateStore` |
| `@json-render/jotai` | Jotai adapter for `StateStore` |
@@ -287,6 +291,69 @@ const 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 });
```
## Features
### Streaming (SpecStream)
@@ -392,6 +459,7 @@ pnpm dev
- http://json-render.localhost:1355 - Docs & Playground
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://react-email-demo.json-render.localhost:1355 - React Email Example
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- Vue Example: run `pnpm dev` in `examples/vue`
+9
View File
@@ -1,5 +1,14 @@
# web
## 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
+364
View File
@@ -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,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&lt;K&gt;</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
@@ -9,54 +9,79 @@ React Native renderer with standard components, providers, and hooks.
### Layout
| Component | Props | Description |
|-----------|-------|-------------|
| `Container` | `padding`, `background`, `borderRadius`, `borderColor`, `flex` | Basic wrapper with styling |
| `Row` | `gap`, `align`, `justify`, `flex`, `wrap` | Horizontal flex layout |
| `Column` | `gap`, `align`, `justify`, `flex` | Vertical flex layout |
| `ScrollContainer` | `direction` | Scrollable area (vertical or horizontal) |
| `SafeArea` | `edges` | Safe area insets for notch/home indicator |
| `Pressable` | `action`, `actionParams` | Touchable wrapper that triggers actions |
| `Spacer` | `size`, `flex` | Fixed or flexible spacing |
| `Divider` | `color`, `thickness` | Thin line separator |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<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>
<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>
<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>
<tr><td><code>ScrollContainer</code></td><td><code>direction</code></td><td>Scrollable area (vertical or horizontal)</td></tr>
<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
| Component | Props | Description |
|-----------|-------|-------------|
| `Heading` | `text`, `level`, `align`, `color` | Heading text (levels 1-6) |
| `Paragraph` | `text`, `align`, `color` | Body text |
| `Label` | `text`, `color`, `bold` | Small label text |
| `Image` | `uri`, `width`, `height`, `resizeMode`, `borderRadius` | Image display |
| `Avatar` | `uri`, `size`, `fallback` | Circular avatar |
| `Badge` | `label`, `color`, `textColor` | Status badge |
| `Chip` | `label`, `selected`, `color` | Tag/chip |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<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>
<tr><td><code>Paragraph</code></td><td><code>text</code>, <code>align</code>, <code>color</code></td><td>Body text</td></tr>
<tr><td><code>Label</code></td><td><code>text</code>, <code>color</code>, <code>bold</code></td><td>Small label text</td></tr>
<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
| Component | Props | Description |
|-----------|-------|-------------|
| `Button` | `label`, `variant`, `size`, `disabled`, `action`, `actionParams` | Pressable button |
| `TextInput` | `placeholder`, `value` (use `$bindState`), `secure`, `keyboardType`, `multiline` | Text input field |
| `Switch` | `checked` (use `$bindState`), `label` | Toggle switch |
| `Checkbox` | `checked` (use `$bindState`), `label` | Checkbox with label |
| `Slider` | `value` (use `$bindState`), `min`, `max`, `step` | Range slider |
| `SearchBar` | `placeholder`, `value` (use `$bindState`) | Search input |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<tbody>
<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>
<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>
<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
| Component | Props | Description |
|-----------|-------|-------------|
| `Spinner` | `size`, `color` | Loading indicator |
| `ProgressBar` | `progress`, `color`, `trackColor` | Progress indicator |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</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
| Component | Props | Description |
|-----------|-------|-------------|
| `Card` | `title`, `subtitle`, `padding` | Card container |
| `ListItem` | `title`, `subtitle`, `leading`, `trailing`, `action`, `actionParams` | List row |
| `Modal` | `visible`, `title` | Bottom sheet modal |
<table>
<thead>
<tr><th>Component</th><th>Props</th><th>Description</th></tr>
</thead>
<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
@@ -68,11 +93,16 @@ React Native renderer with standard components, providers, and hooks.
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
<table>
<thead>
<tr><th>Prop</th><th>Type</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>store</code></td><td><code>StateStore</code></td><td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td></tr>
<tr><td><code>initialState</code></td><td><code>Record&lt;string, unknown&gt;</code></td><td>Initial state model (uncontrolled mode).</td></tr>
<tr><td><code>onStateChange</code></td><td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td><td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td></tr>
</tbody>
</table>
#### External Store (Controlled Mode)
@@ -190,8 +220,13 @@ import { standardComponentDefinitions, standardActionDefinitions } from "@json-r
import { schema } from "@json-render/react-native/schema";
```
| Export | Purpose |
|--------|---------|
| `standardComponentDefinitions` | Catalog definitions for all 25+ standard components |
| `standardActionDefinitions` | Catalog definitions for standard actions (setState, navigate) |
| `schema` | React Native element tree schema |
<table>
<thead>
<tr><th>Export</th><th>Purpose</th></tr>
</thead>
<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>
+42 -8
View File
@@ -15,11 +15,32 @@ React components, providers, and hooks.
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>store</code></td>
<td><code>StateStore</code></td>
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td><code>Record&lt;string, unknown&gt;</code></td>
<td>Initial state model (uncontrolled mode).</td>
</tr>
<tr>
<td><code>onStateChange</code></td>
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
</tr>
</tbody>
</table>
#### External Store (Controlled Mode)
@@ -113,9 +134,22 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;
Convenience wrapper that combines `StateProvider`, `VisibilityProvider`, `ValidationProvider`, and `ActionProvider`. Accepts all their props plus:
| Prop | Type | Description |
|------|------|-------------|
| `functions` | `Record<string, ComputedFunction>` | Named functions for `$computed` expressions in props |
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>functions</code></td>
<td><code>Record&lt;string, ComputedFunction&gt;</code></td>
<td>Named functions for <code>$computed</code> expressions in props</td>
</tr>
</tbody>
</table>
```tsx
<JSONUIProvider
+225 -52
View File
@@ -15,10 +15,27 @@ Your app must have Tailwind CSS configured.
## Entry Points
| Entry Point | Exports | Use For |
|-------------|---------|---------|
| `@json-render/shadcn` | `shadcnComponents` | React implementations |
| `@json-render/shadcn/catalog` | `shadcnComponentDefinitions` | Catalog schemas (no React dependency, safe for server) |
<table>
<thead>
<tr>
<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
@@ -103,69 +120,225 @@ const { registry } = defineRegistry(catalog, {
### Layout
| Component | Description |
|-----------|-------------|
| `Card` | Container card with optional title, description, maxWidth, centered |
| `Stack` | Flex container with direction, gap, align, justify |
| `Grid` | Grid layout with columns (1-6) and gap |
| `Separator` | Visual separator line with orientation |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Card</code></td>
<td>Container card with optional title, description, maxWidth, centered</td>
</tr>
<tr>
<td><code>Stack</code></td>
<td>Flex container with direction, gap, align, justify</td>
</tr>
<tr>
<td><code>Grid</code></td>
<td>Grid layout with columns (1-6) and gap</td>
</tr>
<tr>
<td><code>Separator</code></td>
<td>Visual separator line with orientation</td>
</tr>
</tbody>
</table>
### Navigation
| Component | Description |
|-----------|-------------|
| `Tabs` | Tabbed navigation with tabs array, defaultValue, value |
| `Accordion` | Collapsible sections with items array and type (single/multiple) |
| `Collapsible` | Single collapsible section with title and defaultOpen |
| `Pagination` | Page navigation with totalPages and page |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Tabs</code></td>
<td>Tabbed navigation with tabs array, defaultValue, value</td>
</tr>
<tr>
<td><code>Accordion</code></td>
<td>Collapsible sections with items array and type (single/multiple)</td>
</tr>
<tr>
<td><code>Collapsible</code></td>
<td>Single collapsible section with title and defaultOpen</td>
</tr>
<tr>
<td><code>Pagination</code></td>
<td>Page navigation with totalPages and page</td>
</tr>
</tbody>
</table>
### Overlay
| Component | Description |
|-----------|-------------|
| `Dialog` | Modal dialog with title, description, openPath |
| `Drawer` | Bottom drawer with title, description, openPath |
| `Tooltip` | Hover tooltip with content and text |
| `Popover` | Click-triggered popover with trigger and content |
| `DropdownMenu` | Dropdown menu with label and items array |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Dialog</code></td>
<td>Modal dialog with title, description, openPath</td>
</tr>
<tr>
<td><code>Drawer</code></td>
<td>Bottom drawer with title, description, openPath</td>
</tr>
<tr>
<td><code>Tooltip</code></td>
<td>Hover tooltip with content and text</td>
</tr>
<tr>
<td><code>Popover</code></td>
<td>Click-triggered popover with trigger and content</td>
</tr>
<tr>
<td><code>DropdownMenu</code></td>
<td>Dropdown menu with label and items array</td>
</tr>
</tbody>
</table>
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Heading text with level (h1-h4) |
| `Text` | Paragraph with variant (body, caption, muted, lead, code) |
| `Image` | Image with alt, width, height |
| `Avatar` | User avatar with src, name, size |
| `Badge` | Status badge with text and variant |
| `Alert` | Alert banner with title, message, type |
| `Carousel` | Horizontally scrollable carousel with items |
| `Table` | Data table with columns and rows |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Heading</code></td>
<td>Heading text with level (h1-h4)</td>
</tr>
<tr>
<td><code>Text</code></td>
<td>Paragraph with variant (body, caption, muted, lead, code)</td>
</tr>
<tr>
<td><code>Image</code></td>
<td>Image with alt, width, height</td>
</tr>
<tr>
<td><code>Avatar</code></td>
<td>User avatar with src, name, size</td>
</tr>
<tr>
<td><code>Badge</code></td>
<td>Status badge with text and variant</td>
</tr>
<tr>
<td><code>Alert</code></td>
<td>Alert banner with title, message, type</td>
</tr>
<tr>
<td><code>Carousel</code></td>
<td>Horizontally scrollable carousel with items</td>
</tr>
<tr>
<td><code>Table</code></td>
<td>Data table with columns and rows</td>
</tr>
</tbody>
</table>
### Feedback
| Component | Description |
|-----------|-------------|
| `Progress` | Progress bar with value, max, label |
| `Skeleton` | Loading placeholder with width, height, rounded |
| `Spinner` | Loading spinner with size and label |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Progress</code></td>
<td>Progress bar with value, max, label</td>
</tr>
<tr>
<td><code>Skeleton</code></td>
<td>Loading placeholder with width, height, rounded</td>
</tr>
<tr>
<td><code>Spinner</code></td>
<td>Loading spinner with size and label</td>
</tr>
</tbody>
</table>
### Input
| Component | Description |
|-----------|-------------|
| `Button` | Clickable button with label, variant, disabled |
| `Link` | Anchor link with label and href |
| `Input` | Text input with label, name, type, placeholder, value, checks |
| `Textarea` | Multi-line text input with label, name, placeholder, rows, value, checks |
| `Select` | Dropdown select with label, name, options, value, checks |
| `Checkbox` | Checkbox with label, name, checked |
| `Radio` | Radio button group with label, name, options, value |
| `Switch` | Toggle switch with label, name, checked |
| `Slider` | Range slider with label, min, max, step, value |
| `Toggle` | Toggle button with label, pressed, variant |
| `ToggleGroup` | Group of toggle buttons with items, type, value |
| `ButtonGroup` | Group of buttons with buttons array and selected |
<table>
<thead>
<tr>
<th>Component</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>Button</code></td>
<td>Clickable button with label, variant, disabled</td>
</tr>
<tr>
<td><code>Link</code></td>
<td>Anchor link with label and href</td>
</tr>
<tr>
<td><code>Input</code></td>
<td>Text input with label, name, type, placeholder, value, checks</td>
</tr>
<tr>
<td><code>Textarea</code></td>
<td>Multi-line text input with label, name, placeholder, rows, value, checks</td>
</tr>
<tr>
<td><code>Select</code></td>
<td>Dropdown select with label, name, options, value, checks</td>
</tr>
<tr>
<td><code>Checkbox</code></td>
<td>Checkbox with label, name, checked</td>
</tr>
<tr>
<td><code>Radio</code></td>
<td>Radio button group with label, name, options, value</td>
</tr>
<tr>
<td><code>Switch</code></td>
<td>Toggle switch with label, name, checked</td>
</tr>
<tr>
<td><code>Slider</code></td>
<td>Range slider with label, min, max, step, value</td>
</tr>
<tr>
<td><code>Toggle</code></td>
<td>Toggle button with label, pressed, variant</td>
</tr>
<tr>
<td><code>ToggleGroup</code></td>
<td>Group of toggle buttons with items, type, value</td>
</tr>
<tr>
<td><code>ButtonGroup</code></td>
<td>Group of buttons with buttons array and selected</td>
</tr>
</tbody>
</table>
## Notes
+110 -19
View File
@@ -15,11 +15,32 @@ Vue 3 components, providers, and composables.
</StateProvider>
```
| Prop | Type | Description |
|------|------|-------------|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
<table>
<thead>
<tr>
<th>Prop</th>
<th>Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>store</code></td>
<td><code>StateStore</code></td>
<td>External store (controlled mode). When provided, <code>initialState</code> and <code>onStateChange</code> are ignored.</td>
</tr>
<tr>
<td><code>initialState</code></td>
<td><code>Record&lt;string, unknown&gt;</code></td>
<td>Initial state model (uncontrolled mode).</td>
</tr>
<tr>
<td><code>onStateChange</code></td>
<td><code>{'(changes: Array<{ path: string; value: unknown }>) => void'}</code></td>
<td>Callback when state changes (uncontrolled mode). Called once per <code>set</code> or <code>update</code> with all changed entries.</td>
</tr>
</tbody>
</table>
#### External Store (Controlled Mode)
@@ -230,17 +251,87 @@ const {
## Differences from `@json-render/react`
| API | React | Vue | Note |
|-----|-------|-----|------|
| `useStateStore().state` | `StateModel` (plain object) | `ShallowRef<StateModel>` | Vue reactivity; use `state.value` |
| `useStateValue()` | `T \| undefined` | `ComputedRef<T \| undefined>` | Vue reactivity; use `.value` |
| `useStateBinding()` | `[T \| undefined, setter]` | `[ComputedRef<T \| undefined>, setter]` | Vue reactivity; use `value.value` |
| `useAction().isLoading` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `useFieldValidation().state` | `FieldValidationState` | `ComputedRef<FieldValidationState>` | Vue reactivity; use `.value` |
| `useFieldValidation().errors` | `string[]` | `ComputedRef<string[]>` | Vue reactivity; use `.value` |
| `useFieldValidation().isValid` | `boolean` | `ComputedRef<boolean>` | Vue reactivity; use `.value` |
| `VisibilityContextValue.ctx` | `CoreVisibilityContext` | `ComputedRef<CoreVisibilityContext>` | Vue reactivity; use `ctx.value` |
| `children` type | `React.ReactNode` | `VNode \| VNode[]` | Platform-specific |
| `useBoundProp` | exported | exported | Same API; returns `[value, setValue]` |
| `VisibilityProviderProps` | exported | not exported (no props) | Vue uses slot, no prop needed |
| Streaming hooks | `useUIStream`, `useChatUI` | `useUIStream`, `useChatUI` | Same API; returns Vue `Ref` values |
<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&lt;StateModel&gt;</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&lt;T | undefined&gt;</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&lt;T | undefined&gt;, 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&lt;boolean&gt;</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&lt;FieldValidationState&gt;</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&lt;string[]&gt;</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&lt;boolean&gt;</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&lt;CoreVisibilityContext&gt;</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>
@@ -37,6 +37,10 @@ Requires Tailwind CSS in your project. See the [@json-render/shadcn API referenc
<PackageInstall packages="@json-render/core @json-render/remotion remotion @remotion/player" />
## For React Email
<PackageInstall packages="@json-render/core @json-render/react-email @react-email/components @react-email/render" />
## For External State Management (Optional)
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:
+96 -31
View File
@@ -31,15 +31,23 @@ import { StateProvider } from "@json-render/react";
`StateProvider` now manages state internally. Use `useStateStore()` to access `get`, `set`, and `update`.
| Before | After |
|--------|-------|
| `DataProvider` | `StateProvider` |
| `data` prop | `initialState` prop |
| `getValue` / `setValue` props | Removed (use `useStateStore()` hook for `get` / `set`) |
| `useData` | `useStateStore` |
| `useDataValue` | `useStateValue` |
| `useDataBinding` | `useStateBinding` (deprecated, use `useBoundProp` instead) |
| `DataModel` type | `StateModel` type |
<table>
<thead>
<tr>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody>
<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
@@ -81,10 +89,18 @@ Inside repeat scopes, use `$item` and `$index`:
}
```
| Before | After |
|--------|-------|
| `{ "$path": "/..." }` | `{ "$state": "/..." }` |
| `{ "$data": "/..." }` | `{ "$state": "/..." }` |
<table>
<thead>
<tr>
<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
@@ -315,11 +331,19 @@ const chatPrompt = catalog.prompt({ mode: "chat" });
{ "type": "minLength", "args": { "min": 8 }, "message": "Too short" }
```
| Before | After |
|--------|-------|
| `{ fn: "required" }` | `{ type: "required" }` |
| `ValidationProvider functions={...}` | `ValidationProvider customFunctions={...}` |
| `useFieldValidation(path, checks)` | `useFieldValidation(path, config)` where config is `{ checks, validateOn? }` |
<table>
<thead>
<tr>
<th>Before</th>
<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
@@ -398,16 +422,57 @@ Action params in specs now use `statePath` instead of `path`.
The following exports have been removed from `@json-render/core`:
| Removed | Replacement |
|---------|-------------|
| `createCatalog` | `defineCatalog(schema, config)` |
| `generateCatalogPrompt` | `catalog.prompt()` |
| `generateSystemPrompt` | `catalog.prompt()` |
| `ComponentDefinition` | Use catalog component config directly |
| `CatalogConfig` | Use `defineCatalog` parameters |
| `SystemPromptOptions` | Use `PromptOptions` |
| `LogicExpression` | Use `VisibilityCondition` |
| `AuthState` | Model auth as regular state (e.g. `/auth/isSignedIn`) |
| `evaluateLogicExpression` | Use `evaluateVisibility` |
| `createRendererFromCatalog` | Use `defineRegistry` |
| `traverseTree` (codegen) | Use `traverseSpec` |
<table>
<thead>
<tr>
<th>Removed</th>
<th>Replacement</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>createCatalog</code></td>
<td><code>defineCatalog(schema, config)</code></td>
</tr>
<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>
@@ -9,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-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/react
@@ -278,6 +279,31 @@ export const { registry } = defineRegistry(catalog, {
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` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
+197
View File
@@ -0,0 +1,197 @@
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>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.
## 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.
+21 -4
View File
@@ -136,10 +136,27 @@ An element can watch multiple state paths. Each path maps to one or more action
## When to Use `watch` vs `on`
| Mechanism | Trigger | Use Case |
|-----------|---------|----------|
| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
<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.
+1 -1
View File
@@ -16,7 +16,7 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
npm packages: @json-render/core, @json-render/react, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen
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.
+13
View File
@@ -36,6 +36,7 @@ export const docsNavigation: NavSection[] = [
{
title: "Rendering",
items: [
{ title: "Renderers", href: "/docs/renderers" },
{ title: "Registry", href: "/docs/registry" },
{ title: "Streaming", href: "/docs/streaming" },
{ title: "Generation Modes", href: "/docs/generation-modes" },
@@ -64,11 +65,21 @@ export const docsNavigation: NavSection[] = [
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",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
external: true,
},
{
title: "Image",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
external: true,
},
{
title: "Vue",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
@@ -104,8 +115,10 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/core", href: "/docs/api/core" },
{ 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/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/vue", href: "/docs/api/vue" },
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
+3
View File
@@ -26,6 +26,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/computed-values": "Computed Values",
"docs/visibility": "Visibility",
"docs/watchers": "Watchers",
"docs/renderers": "Renderers",
"docs/generation-modes": "Generation Modes",
"docs/code-export": "Code Export",
"docs/custom-schema": "Custom Schema & Renderer",
@@ -42,8 +43,10 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/react": "@json-render/react API",
"docs/api/vue": "@json-render/vue 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/codegen": "@json-render/codegen API",
"docs/api/image": "@json-render/image API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
};
+3 -2
View File
@@ -1,10 +1,11 @@
{
"name": "web",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"license": "Apache-2.0",
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -50,7 +51,7 @@
"tailwind-merge": "^3.4.0",
"unist-util-visit": "5.1.0",
"vaul": "^1.1.2",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
+9
View File
@@ -1,5 +1,14 @@
# example-chat
## 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
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "example-chat",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless chat-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -33,7 +34,7 @@
"streamdown": "^2.2.0",
"tailwind-merge": "^3.4.0",
"three": "^0.182.0",
"zod": "4.3.5"
"zod": "4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
+9
View File
@@ -1,5 +1,14 @@
# example-dashboard
## 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
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "example-dashboard",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless dashboard-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -39,7 +40,7 @@
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
+9
View File
@@ -0,0 +1,9 @@
# example-image
## 0.1.1
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/image@0.11.0
+34
View File
@@ -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();
}
+79
View File
@@ -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",
},
});
}
+124
View File
@@ -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;
}
}
+22
View File
@@ -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>
);
}
+627
View File
@@ -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>
);
}
+23
View File
@@ -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 };
+143
View File
@@ -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,
};
+12
View File
@@ -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",
},
},
];
+7
View File
@@ -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,
});
+771
View File
@@ -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: [],
},
},
},
},
];
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -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.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@resvg/resvg-js", "satori"],
};
export default nextConfig;
+44
View File
@@ -0,0 +1,44 @@
{
"name": "example-image",
"version": "0.1.1",
"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"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+9
View File
@@ -1,5 +1,14 @@
# example-no-ai
## 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
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "example-no-ai",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless no-ai-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -23,7 +24,7 @@
"react-dom": "19.2.4",
"react-confetti-explosion": "^3.0.3",
"tailwind-merge": "^3.4.0",
"zod": "4.3.5"
"zod": "4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
+9
View File
@@ -0,0 +1,9 @@
# Vercel AI Gateway
# Automatically authenticated when deployed on Vercel
# For local development, get your key from https://vercel.com/ai-gateway
AI_GATEWAY_API_KEY=
# AI Model Configuration
# Override the default model used for email generation
# Default: anthropic/claude-haiku-4.5
AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
@@ -0,0 +1,47 @@
import {
renderToHtml,
renderToPlainText,
} from "@json-render/react-email/render";
import { examples } from "@/lib/examples";
import type { Spec } from "@json-render/core";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const name = searchParams.get("name") ?? "vercel-invite";
const plain = searchParams.get("plain") === "1";
const example = examples.find((e) => e.name === name);
if (!example) {
return new Response("Example not found", { status: 404 });
}
return emailResponse(example.spec, plain);
}
export async function POST(req: Request) {
const { spec, plain } = (await req.json()) as {
spec: Spec;
plain?: boolean;
};
if (!spec || !spec.root || !spec.elements) {
return new Response("Invalid spec", { status: 400 });
}
return emailResponse(spec, plain ?? false);
}
async function emailResponse(spec: Spec, plain: boolean) {
const content = plain
? await renderToPlainText(spec)
: await renderToHtml(spec);
return new Response(content, {
headers: {
"Content-Type": plain
? "text/plain; charset=utf-8"
: "text/html; charset=utf-8",
"Cache-Control": "no-store",
},
});
}
@@ -0,0 +1,34 @@
import { streamText } from "ai";
import { buildUserPrompt, type Spec } from "@json-render/core";
import { emailCatalog } from "@/lib/catalog";
export const maxDuration = 60;
const SYSTEM_PROMPT = emailCatalog.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();
}
+130
View File
@@ -0,0 +1,130 @@
@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;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "json-render React Email Example",
description:
"Generate HTML emails from JSON specs with @json-render/react-email",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+608
View File
@@ -0,0 +1,608 @@
"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 { FileText, Download, Loader2, ArrowRight, Square } from "lucide-react";
type Mode = "scratch" | "example";
type MobileView = "json" | "preview";
interface Selection {
mode: Mode;
exampleName?: string;
}
const HTML_REFRESH_INTERVAL_MS = 2000;
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];
if (!root) return false;
if (root.type !== "Html" || !root.children?.length) return false;
const childTypes = root.children.map((id) => spec.elements[id]?.type);
return childTypes.includes("Head") || childTypes.includes("Body");
}
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 [htmlContent, setHtmlContent] = 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 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;
useEffect(() => {
inputRef.current?.focus();
}, [selection.mode, selection.exampleName]);
const fetchHtml = useCallback(async (spec: Spec, signal?: AbortSignal) => {
const res = await fetch("/api/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec }),
signal,
});
if (!res.ok) throw new Error("Failed to render email");
const html = await res.text();
setHtmlContent(html);
}, []);
// Fetch HTML for example specs on selection change
useEffect(() => {
if (selection.mode !== "example" || !currentExample || generatedSpec)
return;
fetchHtml(currentExample.spec).catch(() => {});
}, [selection.mode, currentExample, generatedSpec, fetchHtml]);
// Progressive HTML refresh during generation
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);
fetchHtml(spec)
.catch(() => {})
.finally(() => setRefreshing(false));
}, HTML_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [generating, fetchHtml]);
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 fetchHtml(finalSpec);
} catch (e) {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : "Something went wrong");
setGenerating(false);
}
}, [prompt, selection, currentExample, fetchHtml]);
const handleStop = useCallback(() => {
abortRef.current?.abort();
setGenerating(false);
if (isRenderableSpec(generatedSpec)) {
fetchHtml(generatedSpec).catch(() => {});
}
}, [generatedSpec, fetchHtml]);
const select = (next: Selection) => {
abortRef.current?.abort();
setSelection(next);
setGeneratedSpec(null);
setHtmlContent(null);
setError(null);
setPrompt("");
setGenerating(false);
setExamplesSheetOpen(false);
};
const handleDownload = async () => {
if (!activeSpec) return;
const spec = generatedSpec ?? currentExample?.spec;
if (!spec) return;
const res = await fetch("/api/email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec }),
});
if (!res.ok) return;
const html = await res.text();
const blob = new Blob([html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "email.html";
a.click();
URL.revokeObjectURL(url);
};
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 email";
// ---------------------------------------------------------------------------
// 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">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-mono text-muted-foreground">
json-render / react-email
</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 email 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: Email 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 w-3 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-600">
{htmlContent ? (
<div className="h-full flex justify-center p-5 overflow-auto">
<iframe
srcDoc={htmlContent}
className="w-[620px] h-full border-none bg-white shadow-sm"
title="Email preview"
/>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">
{selection.mode === "scratch"
? "Enter a prompt to generate an email"
: "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 px-3 h-9 flex items-center gap-3 shrink-0">
<button
onClick={() => setExamplesSheetOpen(true)}
className="text-xs font-mono font-medium px-1.5 py-0.5 rounded bg-muted text-foreground shrink-0"
>
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</button>
{(["json", "preview"] as const).map((tab) => (
<button
key={tab}
onClick={() => setMobileView(tab)}
className={cn(
"text-xs font-mono transition-colors shrink-0",
mobileView === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{tab}
</button>
))}
{(generating || refreshing) && (
<Loader2 className="h-3 w-3 text-muted-foreground animate-spin shrink-0" />
)}
<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" />
</button>
)}
</div>
<div ref={mobileCodeScrollRef} className="flex-1 min-h-0 overflow-auto">
{mobileView === "json" ? (
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
{jsonCode}
</pre>
) : (
<div className="h-full relative bg-neutral-600">
{htmlContent ? (
<div className="h-full flex justify-center p-5 overflow-auto">
<iframe
srcDoc={htmlContent}
className="w-[620px] h-full border-none bg-white shadow-sm"
title="Email preview"
/>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center gap-2 text-neutral-400">
<FileText className="h-10 w-10" />
<p className="text-sm">Enter a prompt to generate an email</p>
</div>
)}
</div>
)}
</div>
<div
className="border-t border-border p-3 shrink-0 cursor-text"
onMouseDown={(e) => {
const target = e.target as HTMLElement;
if (!target.closest("button") && target.tagName !== "TEXTAREA") {
e.preventDefault();
mobileInputRef.current?.focus();
}
}}
>
<textarea
ref={mobileInputRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selection.mode === "scratch"
? "Describe the email you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<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>
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
<SheetContent side="left" className="w-80 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",
)}
>
From scratch
</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>
</div>
);
}
+23
View File
@@ -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,64 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -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,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
@@ -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,91 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+8
View File
@@ -0,0 +1,8 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-email/server";
import { standardComponentDefinitions } from "@json-render/react-email/catalog";
export const emailCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: {},
});
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -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.
+11
View File
@@ -0,0 +1,11 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: [
"@react-email/components",
"@react-email/render",
"@json-render/react-email",
],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "example-react-email",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "portless react-email-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@ai-sdk/gateway": "^3.0.52",
"@json-render/core": "workspace:*",
"@json-render/react-email": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"ai": "6.0.94",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"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.5"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+8
View File
@@ -1,5 +1,13 @@
# example-react-native
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react-native@0.11.0
## 0.1.3
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"main": "expo-router/entry",
"scripts": {
@@ -25,7 +25,7 @@
"react-native": "0.81.4",
"react-native-safe-area-context": "~5.4.0",
"react-native-screens": "~4.11.1",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.0",
+8
View File
@@ -1,5 +1,13 @@
# example-react-pdf
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react-pdf@0.11.0
## 0.1.3
### Patch Changes
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "example-react-pdf",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless react-pdf-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -25,7 +26,7 @@
"react-resizable-panels": "^4.4.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.1.18",
"zod": "4.3.5"
"zod": "4.3.6"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
+8
View File
@@ -1,5 +1,13 @@
# example-remotion
## 0.1.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/remotion@0.11.0
## 0.1.3
### Patch Changes
+3 -2
View File
@@ -1,9 +1,10 @@
{
"name": "example-remotion",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless remotion-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
@@ -26,7 +27,7 @@
"remotion": "4.0.418",
"shiki": "^3.21.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@internal/eslint-config": "workspace:*",
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless stripe-api-demo.json-render next dev",
"build": "next build",
"start": "next start --port 3001"
@@ -1,5 +1,13 @@
# com.example.json-render-demo
## 0.0.5
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
## 0.0.4
### Patch Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.4",
"version": "0.0.5",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -10,7 +10,7 @@
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"engines": {
"node": ">=14"
@@ -1,5 +1,13 @@
# com.example.json-render-fullpage-demo
## 0.0.5
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
## 0.0.4
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.4",
"version": "0.0.5",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
@@ -10,7 +10,7 @@
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
"stripe": "^13.11.0",
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"engines": {
"node": ">=14"
+9
View File
@@ -1,5 +1,14 @@
# vite-renderers
## 0.1.2
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/react@0.11.0
- @json-render/vue@0.11.0
## 0.1.1
### Patch Changes
+2 -1
View File
@@ -1,8 +1,9 @@
{
"name": "vite-renderers",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless vite-renderers.json-render vite",
"build": "vite build",
"preview": "vite preview"
+8
View File
@@ -1,5 +1,13 @@
# example-vue
## 0.1.2
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
- @json-render/vue@0.11.0
## 0.1.1
### Patch Changes
+2 -1
View File
@@ -1,8 +1,9 @@
{
"name": "example-vue",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
"dev": "portless vue.json-render vite",
"build": "vite build",
"preview": "vite preview"
+1
View File
@@ -12,6 +12,7 @@
},
"scripts": {
"build": "turbo run build",
"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": "turbo run dev --concurrency 20",
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx}\"",
+7
View File
@@ -1,5 +1,12 @@
# @json-render/codegen
## 0.11.0
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
## 0.10.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+16
View File
@@ -1,5 +1,21 @@
# @json-render/core
## 0.11.0
### Minor Changes
- 3f1e71e: Image renderer: generate SVG and PNG from JSON specs.
### New: `@json-render/image` Package
Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners.
- `renderToSvg(spec, options)` — render spec to SVG string
- `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`)
- 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer
- `standardComponentDefinitions` catalog for AI prompt generation
- Server-safe import path: `@json-render/image/server`
- Sub-path exports: `/render`, `/catalog`, `/server`
## 0.10.0
### Minor Changes
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
@@ -50,7 +50,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"zod": "^4.0.0"
"zod": "^4.3.6"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
+22
View File
@@ -0,0 +1,22 @@
# @json-render/image
## 0.11.0
### Minor Changes
- 3f1e71e: Image renderer: generate SVG and PNG from JSON specs.
### New: `@json-render/image` Package
Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners.
- `renderToSvg(spec, options)` — render spec to SVG string
- `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`)
- 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer
- `standardComponentDefinitions` catalog for AI prompt generation
- Server-safe import path: `@json-render/image/server`
- Sub-path exports: `/render`, `/catalog`, `/server`
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
+167
View File
@@ -0,0 +1,167 @@
# @json-render/image
Image renderer for `@json-render/core`. Generate SVG and PNG images from JSON specs 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
```
## Quick Start
### Render a spec to SVG
```typescript
import { renderToSvg } from "@json-render/image/render";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading", "subtitle"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
subtitle: {
type: "Text",
props: { text: "Generated from JSON", fontSize: 24, color: "#a0a0b0" },
children: [],
},
},
};
const svg = await renderToSvg(spec, {
fonts: [
{
name: "Inter",
data: await fetch("https://example.com/Inter-Regular.ttf").then((r) =>
r.arrayBuffer()
),
weight: 400,
style: "normal",
},
],
});
```
### Render to PNG
```typescript
import { renderToPng } from "@json-render/image/render";
const png = await renderToPng(spec, {
fonts: [
{
name: "Inter",
data: await readFile("./Inter-Regular.ttf"),
weight: 400,
style: "normal",
},
],
});
// Write to file
await writeFile("output.png", png);
```
### With a custom catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema, renderToSvg } from "@json-render/image";
import { standardComponentDefinitions } from "@json-render/image/catalog";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Badge: {
props: z.object({
label: z.string(),
color: z.string().nullable(),
}),
slots: [],
description: "A colored badge label",
},
},
});
```
## Standard Components
### Root
| Component | Description |
|-----------|-------------|
| `Frame` | Root image container. Defines width, height, and background. Must be the root element. |
### Layout
| Component | Description |
|-----------|-------------|
| `Box` | Generic container with padding, margin, background, border, and flex alignment. |
| `Row` | Horizontal flex layout with gap, align, justify. |
| `Column` | Vertical flex layout with gap, align, justify. |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | h1-h4 heading text with color and alignment. |
| `Text` | Body text with fontSize, color, weight, style, and alignment. |
| `Image` | Image from a URL with width, height, and borderRadius. |
### Decorative
| Component | Description |
|-----------|-------------|
| `Divider` | Horizontal line separator. |
| `Spacer` | Empty vertical space. |
## Server-Side APIs
```typescript
import { renderToSvg, renderToPng } from "@json-render/image/render";
// Render to an SVG string
const svg = await renderToSvg(spec, { fonts });
// Render to a PNG buffer (requires @resvg/resvg-js)
const png = await renderToPng(spec, { fonts });
```
### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `fonts` | `SatoriOptions['fonts']` | `[]` | Font data for text rendering |
| `width` | `number` | Frame prop | Override image width |
| `height` | `number` | Frame prop | Override image height |
| `registry` | `Record<string, Component>` | `{}` | Custom component overrides |
| `includeStandard` | `boolean` | `true` | Include standard components |
| `state` | `Record<string, unknown>` | `{}` | Initial state values |
## Server-Safe Import
Import schema and catalog definitions without pulling in React or Satori:
```typescript
import { schema, standardComponentDefinitions } from "@json-render/image/server";
```
## License
Apache-2.0
+85
View File
@@ -0,0 +1,85 @@
{
"name": "@json-render/image",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
"json",
"image",
"svg",
"png",
"og",
"satori",
"ai",
"generative-ui",
"llm",
"renderer"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/image"
},
"homepage": "https://github.com/vercel-labs/json-render#readme",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.mjs",
"require": "./dist/server.js"
},
"./catalog": {
"types": "./dist/catalog.d.ts",
"import": "./dist/catalog.mjs",
"require": "./dist/catalog.js"
},
"./render": {
"types": "./dist/render.d.ts",
"import": "./dist/render.mjs",
"require": "./dist/render.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"check-types": "tsc --noEmit",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"satori": "^0.19.2"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.5.1",
"typescript": "^5.4.5",
"zod": "^4.3.6"
},
"peerDependencies": {
"@resvg/resvg-js": ">=2.0.0",
"react": "^18.0.0 || ^19.0.0",
"zod": "^4.0.0"
},
"peerDependenciesMeta": {
"@resvg/resvg-js": {
"optional": true
}
}
}
+30
View File
@@ -0,0 +1,30 @@
import type {
Catalog,
InferCatalogComponents,
InferComponentProps,
StateModel,
} from "@json-render/core";
export type { StateModel };
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: React.ReactNode;
emit: (event: string) => void;
}
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => React.ReactNode;
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
+228
View File
@@ -0,0 +1,228 @@
import { z } from "zod";
/**
* Standard component definitions for image catalogs.
*
* These define the available image components with their Zod prop schemas.
* All components render to Satori-compatible JSX (HTML-like elements with
* inline CSS flexbox styles).
*/
export const standardComponentDefinitions = {
// ==========================================================================
// Root
// ==========================================================================
Frame: {
props: z.object({
width: z.number(),
height: z.number(),
backgroundColor: z.string().nullable(),
padding: z.number().nullable(),
display: z.enum(["flex", "none"]).nullable(),
flexDirection: z.enum(["row", "column"]).nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
}),
slots: ["default"],
description:
"Root image container. Defines the output image dimensions and background. Must be the root element.",
example: { width: 1200, height: 630, backgroundColor: "#ffffff" },
},
// ==========================================================================
// Layout Components
// ==========================================================================
Box: {
props: z.object({
padding: z.number().nullable(),
paddingTop: z.number().nullable(),
paddingBottom: z.number().nullable(),
paddingLeft: z.number().nullable(),
paddingRight: z.number().nullable(),
margin: z.number().nullable(),
backgroundColor: z.string().nullable(),
borderWidth: z.number().nullable(),
borderColor: z.string().nullable(),
borderRadius: z.number().nullable(),
flex: z.number().nullable(),
width: z.union([z.number(), z.string()]).nullable(),
height: z.union([z.number(), z.string()]).nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
flexDirection: z.enum(["row", "column"]).nullable(),
position: z.enum(["relative", "absolute"]).nullable(),
top: z.number().nullable(),
left: z.number().nullable(),
right: z.number().nullable(),
bottom: z.number().nullable(),
overflow: z.enum(["visible", "hidden"]).nullable(),
}),
slots: ["default"],
description:
"Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.",
example: {
padding: 20,
backgroundColor: "#f9f9f9",
borderRadius: 8,
alignItems: "center",
},
},
Row: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Horizontal flex layout. Use for placing elements side by side.",
example: { gap: 10, alignItems: "center" },
},
Column: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
}),
slots: ["default"],
description:
"Vertical flex layout. Use for stacking elements top to bottom.",
example: { gap: 8, padding: 10 },
},
// ==========================================================================
// Content Components
// ==========================================================================
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
letterSpacing: z.union([z.number(), z.string()]).nullable(),
lineHeight: z.number().nullable(),
}),
slots: [],
description:
"Heading text at various levels. h1 is largest, h4 is smallest.",
example: { text: "Hello World", level: "h1", color: "#000000" },
},
Text: {
props: z.object({
text: z.string(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
fontWeight: z.enum(["normal", "bold"]).nullable(),
fontStyle: z.enum(["normal", "italic"]).nullable(),
lineHeight: z.number().nullable(),
letterSpacing: z.union([z.number(), z.string()]).nullable(),
textDecoration: z.enum(["none", "underline", "line-through"]).nullable(),
}),
slots: [],
description:
"Body text with configurable size, color, weight, and alignment.",
example: { text: "Some content here.", fontSize: 16, color: "#333333" },
},
Image: {
props: z.object({
src: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
borderRadius: z.number().nullable(),
objectFit: z.enum(["contain", "cover", "fill", "none"]).nullable(),
}),
slots: [],
description:
"Image from a URL. Specify width and/or height to control size. For placeholder images use https://picsum.photos/{width}/{height}?random={n}.",
example: {
src: "https://picsum.photos/400/300?random=1",
width: 400,
height: 300,
},
},
// ==========================================================================
// Decorative Components
// ==========================================================================
Divider: {
props: z.object({
color: z.string().nullable(),
thickness: z.number().nullable(),
marginTop: z.number().nullable(),
marginBottom: z.number().nullable(),
}),
slots: [],
description: "Horizontal line separator between content sections.",
example: { color: "#e5e7eb", thickness: 1 },
},
Spacer: {
props: z.object({
height: z.number().nullable(),
}),
slots: [],
description: "Empty vertical space between elements.",
example: { height: 20 },
},
};
export type StandardComponentDefinitions = typeof standardComponentDefinitions;
export type StandardComponentProps<
K extends keyof StandardComponentDefinitions,
> = StandardComponentDefinitions[K]["props"] extends { _output: infer O }
? O
: z.output<StandardComponentDefinitions[K]["props"]>;
+1
View File
@@ -0,0 +1 @@
export { standardComponents } from "./standard";
+265
View File
@@ -0,0 +1,265 @@
import React from "react";
import type { ComponentRenderProps, ComponentRegistry } from "../types";
import type { StandardComponentProps } from "../catalog";
const headingSizes: Record<string, number> = {
h1: 48,
h2: 36,
h3: 28,
h4: 22,
};
const headingWeights: Record<string, number> = {
h1: 700,
h2: 700,
h3: 600,
h4: 600,
};
/**
* Satori crashes on explicit `undefined` style values (e.g. `padding: undefined`).
* Strip them so only defined properties are passed.
*/
function cleanStyle(raw: Record<string, unknown>): React.CSSProperties {
const out: Record<string, unknown> = {};
for (const k in raw) {
if (raw[k] !== undefined && raw[k] !== null) {
out[k] = raw[k];
}
}
return out as React.CSSProperties;
}
// =============================================================================
// Root
// =============================================================================
function FrameComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Frame">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: p.display ?? "flex",
flexDirection: p.flexDirection ?? "column",
width: p.width,
height: p.height,
backgroundColor: p.backgroundColor ?? "white",
padding: p.padding,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
})}
>
{children}
</div>
);
}
// =============================================================================
// Layout Components
// =============================================================================
function BoxComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Box">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: p.flexDirection ?? "column",
padding: p.padding,
paddingTop: p.paddingTop,
paddingBottom: p.paddingBottom,
paddingLeft: p.paddingLeft,
paddingRight: p.paddingRight,
margin: p.margin,
backgroundColor: p.backgroundColor,
borderWidth: p.borderWidth,
borderColor: p.borderColor,
borderRadius: p.borderRadius,
borderStyle: p.borderWidth ? "solid" : undefined,
flex: p.flex,
width: p.width,
height: p.height,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
position: p.position,
top: p.top,
left: p.left,
right: p.right,
bottom: p.bottom,
overflow: p.overflow,
})}
>
{children}
</div>
);
}
function RowComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Row">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: "row",
gap: p.gap,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
padding: p.padding,
flex: p.flex,
flexWrap: p.wrap ? "wrap" : undefined,
})}
>
{children}
</div>
);
}
function ColumnComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Column">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
flexDirection: "column",
gap: p.gap,
alignItems: p.alignItems,
justifyContent: p.justifyContent,
padding: p.padding,
flex: p.flex,
})}
>
{children}
</div>
);
}
// =============================================================================
// Content Components
// =============================================================================
function HeadingComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
const p = element.props;
const level = p.level ?? "h2";
return (
<div
style={cleanStyle({
display: "flex",
fontSize: headingSizes[level] ?? 36,
fontWeight: headingWeights[level] ?? 700,
color: p.color ?? "black",
textAlign: p.align ?? "left",
letterSpacing: p.letterSpacing,
lineHeight: p.lineHeight ?? 1.2,
})}
>
{p.text}
</div>
);
}
function TextComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Text">>) {
const p = element.props;
return (
<div
style={cleanStyle({
display: "flex",
fontSize: p.fontSize ?? 16,
color: p.color ?? "black",
textAlign: p.align ?? "left",
fontWeight: p.fontWeight === "bold" ? 700 : 400,
fontStyle: p.fontStyle ?? "normal",
lineHeight: p.lineHeight ?? 1.4,
letterSpacing: p.letterSpacing,
textDecoration: p.textDecoration,
})}
>
{p.text}
</div>
);
}
function ImageComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Image">>) {
const p = element.props;
return (
<img
src={p.src}
width={p.width ?? undefined}
height={p.height ?? undefined}
style={cleanStyle({
borderRadius: p.borderRadius,
objectFit: p.objectFit ?? "contain",
})}
/>
);
}
// =============================================================================
// Decorative Components
// =============================================================================
function DividerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Divider">>) {
const p = element.props;
return (
<div
style={{
display: "flex",
width: "100%",
borderBottom: `${p.thickness ?? 1}px solid ${p.color ?? "#e5e7eb"}`,
marginTop: p.marginTop ?? 8,
marginBottom: p.marginBottom ?? 8,
}}
/>
);
}
function SpacerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Spacer">>) {
const p = element.props;
return <div style={{ display: "flex", height: p.height ?? 20 }} />;
}
// =============================================================================
// Registry
// =============================================================================
export const standardComponents: ComponentRegistry = {
Frame: FrameComponent,
Box: BoxComponent,
Row: RowComponent,
Column: ColumnComponent,
Heading: HeadingComponent,
Text: TextComponent,
Image: ImageComponent,
Divider: DividerComponent,
Spacer: SpacerComponent,
};

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