Compare commits

...
Author SHA1 Message Date
b6f12d4d53 Feat/react email support (#159)
* add react-email workspace dependencies

* add react-email package

* add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt

* add render tests for react-email package

* fix missing style prop on stripe-welcome logo Image

* add react email documentation

* add json render react email skill

* 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.

* 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

* merge latest

* fix: update tsconfig extends from @repo to @internal/typescript-config

* fixes

* fixes

---------

Co-authored-by: WManzoli <willmanzoli@gmail.com>
Co-authored-by: Chris Tate <chris@ctate.dev>
2026-03-02 16:43:03 -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
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
117 changed files with 8327 additions and 30 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": [],
+35
View File
@@ -73,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>
@@ -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:
@@ -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.
+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",
};
+2 -1
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",
+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
+2 -1
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",
+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
+2 -1
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",
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-image",
"version": "0.1.0",
"version": "0.1.1",
"type": "module",
"private": true,
"scripts": {
+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
+2 -1
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",
+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
+1 -1
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": {
+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
+2 -1
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",
+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
+2 -1
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",
+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
+1 -1
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~~",
@@ -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~~",
+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
+1 -1
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": [
+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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.1.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/jotai
## 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/jotai",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
+147
View File
@@ -0,0 +1,147 @@
# @json-render/react-email
React Email renderer for `@json-render/core`. Generate HTML and plain-text emails from JSON specs 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
```
## Quick Start
### Render a spec to HTML
```typescript
import { renderToHtml } from "@json-render/react-email";
import type { Spec } from "@json-render/core";
const spec: 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);
```
### With a custom catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
import { standardComponentDefinitions } from "@json-render/react-email/catalog";
import { Container, Heading, Text } from "@react-email/components";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Alert: {
props: z.object({
message: z.string(),
variant: z.enum(["info", "success", "warning"]).nullable(),
}),
slots: [],
description: "A highlighted message block",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Alert: ({ props }) => (
<Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
<Text style={{ margin: 0 }}>{props.message}</Text>
</Container>
),
},
});
const html = await renderToHtml(spec, { registry });
```
## Standard Components
### Document structure
| Component | Description |
|-----------|-------------|
| `Html` | Top-level email wrapper. Must be the root element. |
| `Head` | Email head section. |
| `Body` | Email body wrapper. |
### Layout
| Component | Description |
|-----------|-------------|
| `Container` | Constrains content width. |
| `Section` | Groups related content. |
| `Row` | Horizontal layout row. |
| `Column` | Column within a Row. |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Heading text (h1–h6). |
| `Text` | Body text paragraph. |
| `Link` | Hyperlink. |
| `Button` | Call-to-action button. |
| `Image` | Image from URL. |
| `Hr` | Horizontal rule. |
### Utility
| Component | Description |
|-----------|-------------|
| `Preview` | Inbox preview text. |
| `Markdown` | Markdown content as email-safe HTML. |
## Server-Side APIs
```typescript
import { renderToHtml, renderToPlainText } from "@json-render/react-email";
const html = await renderToHtml(spec);
const plainText = await renderToPlainText(spec);
```
Both accept an optional second argument with:
- `registry` — Custom component registry (merged with standard components)
- `includeStandard` — Include built-in standard components (default: `true`)
- `state` — Initial state for `$state` / `$cond` dynamic prop resolution
## 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";
```
## Documentation
Full API reference: [json-render.dev/docs/api/react-email](https://json-render.dev/docs/api/react-email).
Example app: [examples/react-email](https://github.com/vercel-labs/json-render/tree/main/examples/react-email).
## License
Apache-2.0
+78
View File
@@ -0,0 +1,78 @@
{
"name": "@json-render/react-email",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
"keywords": [
"json",
"email",
"react-email",
"ai",
"generative-ui",
"llm",
"renderer",
"html"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-email"
},
"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:*",
"@react-email/components": "^1.0.8",
"@react-email/render": "^2.0.4"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"@types/react": "19.2.3",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"zod": "^4.0.0"
},
"peerDependencies": {
"react": "^19.0.0",
"zod": "^4.0.0"
}
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import type {
Catalog,
InferCatalogComponents,
InferComponentProps,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => ReactNode;
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
+219
View File
@@ -0,0 +1,219 @@
import { z } from "zod";
const styleSchema = z.record(z.string(), z.any()).nullable();
/**
* Standard component definitions for React Email catalogs.
*
* These define the available email components with their Zod prop schemas.
* All components render using @react-email/components primitives.
*/
export const standardComponentDefinitions = {
// ==========================================================================
// Document Structure
// ==========================================================================
Html: {
props: z.object({
lang: z.string().nullable(),
dir: z.enum(["ltr", "rtl"]).nullable(),
}),
slots: ["default"],
description:
"Top-level HTML email wrapper. Must be the root element. Children should include Head and Body.",
example: { lang: "en", dir: "ltr" },
},
Head: {
props: z.object({}),
slots: ["default"],
description:
"Email head section. Place inside Html. Can contain metadata but typically left empty.",
example: {},
},
Body: {
props: z.object({
style: styleSchema,
}),
slots: ["default"],
description:
"Email body wrapper. Place inside Html after Head. Contains all visible email content.",
example: { style: { backgroundColor: "#f6f9fc" } },
},
Container: {
props: z.object({
style: styleSchema,
}),
slots: ["default"],
description:
"Constrains content width for email clients. Place inside Body. Typically max-width 600px.",
example: {
style: {
maxWidth: "600px",
margin: "0 auto",
padding: "20px 0 48px",
},
},
},
Section: {
props: z.object({
style: styleSchema,
}),
slots: ["default"],
description:
"Groups related content. Renders as a table-based section for email compatibility.",
example: { style: { padding: "24px", backgroundColor: "#ffffff" } },
},
Row: {
props: z.object({
style: styleSchema,
}),
slots: ["default"],
description:
"Horizontal layout row. Use inside Section for multi-column layouts.",
example: { style: {} },
},
Column: {
props: z.object({
style: styleSchema,
}),
slots: ["default"],
description:
"Column within a Row. Set width via style for proportional layouts.",
example: { style: { width: "50%" } },
},
// ==========================================================================
// Content Components
// ==========================================================================
Heading: {
props: z.object({
text: z.string(),
as: z.enum(["h1", "h2", "h3", "h4", "h5", "h6"]).nullable(),
style: styleSchema,
}),
slots: [],
description:
"Heading text at various levels. h1 is largest, h6 is smallest.",
example: { text: "Welcome!", as: "h1" },
},
Text: {
props: z.object({
text: z.string(),
style: styleSchema,
}),
slots: [],
description:
"Body text paragraph. Use style for font size, color, weight, and alignment.",
example: { text: "Thank you for signing up." },
},
Link: {
props: z.object({
text: z.string(),
href: z.string(),
style: styleSchema,
}),
slots: [],
description: "Hyperlink with visible text and a URL.",
example: {
text: "Visit our website",
href: "https://example.com",
style: { color: "#2563eb" },
},
},
Button: {
props: z.object({
text: z.string(),
href: z.string(),
style: styleSchema,
}),
slots: [],
description:
"Call-to-action button rendered as a link styled as a button. Provide text and href.",
example: {
text: "Get Started",
href: "https://example.com",
style: {
backgroundColor: "#5F51E8",
borderRadius: "3px",
color: "#fff",
padding: "12px 20px",
},
},
},
Image: {
props: z.object({
src: z.string(),
alt: z.string().nullable(),
width: z.number().nullable(),
height: z.number().nullable(),
style: styleSchema,
}),
slots: [],
description:
"Image from a URL. src must be a fully qualified URL. Specify width and height for consistent rendering.",
example: {
src: "https://picsum.photos/400/200?random=1",
alt: "Hero image",
width: 400,
height: 200,
},
},
Hr: {
props: z.object({
style: styleSchema,
}),
slots: [],
description: "Horizontal rule separator between content sections.",
example: {
style: { borderColor: "#e6ebf1", margin: "20px 0" },
},
},
// ==========================================================================
// Utility Components
// ==========================================================================
Preview: {
props: z.object({
text: z.string(),
}),
slots: [],
description:
"Preview text shown in email client inboxes before the email is opened. Place inside Html.",
example: { text: "You have a new message from Acme Corp" },
},
Markdown: {
props: z.object({
content: z.string(),
markdownContainerStyles: styleSchema,
markdownCustomStyles: z.record(z.string(), z.any()).nullable(),
}),
slots: [],
description:
"Renders markdown content as email-safe HTML. Supports headings, paragraphs, lists, links, bold, italic, and code.",
example: {
content: "# Hello\n\nThis is **bold** and *italic* text.",
},
},
};
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"]>;
@@ -0,0 +1 @@
export { standardComponents } from "./standard";
@@ -0,0 +1,212 @@
import React from "react";
import {
Html as EmailHtml,
Head as EmailHead,
Body as EmailBody,
Container as EmailContainer,
Section as EmailSection,
Row as EmailRow,
Column as EmailColumn,
Heading as EmailHeading,
Text as EmailText,
Link as EmailLink,
Button as EmailButton,
Img as EmailImg,
Hr as EmailHr,
Preview as EmailPreview,
Markdown as EmailMarkdown,
} from "@react-email/components";
import type { ComponentRenderProps } from "../renderer";
import type { ComponentRegistry } from "../renderer";
import type { StandardComponentProps } from "../catalog";
// =============================================================================
// Document Structure
// =============================================================================
function HtmlComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Html">>) {
const p = element.props;
return (
<EmailHtml lang={p.lang ?? undefined} dir={p.dir ?? undefined}>
{children}
</EmailHtml>
);
}
function HeadComponent({
children,
}: ComponentRenderProps<StandardComponentProps<"Head">>) {
return <EmailHead>{children}</EmailHead>;
}
function BodyComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Body">>) {
const p = element.props;
return <EmailBody style={p.style ?? undefined}>{children}</EmailBody>;
}
function ContainerComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Container">>) {
const p = element.props;
return (
<EmailContainer style={p.style ?? undefined}>{children}</EmailContainer>
);
}
function SectionComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Section">>) {
const p = element.props;
return <EmailSection style={p.style ?? undefined}>{children}</EmailSection>;
}
function RowComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Row">>) {
const p = element.props;
return <EmailRow style={p.style ?? undefined}>{children}</EmailRow>;
}
function ColumnComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Column">>) {
const p = element.props;
return <EmailColumn style={p.style ?? undefined}>{children}</EmailColumn>;
}
// =============================================================================
// Content Components
// =============================================================================
function HeadingComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
const p = element.props;
return (
<EmailHeading as={p.as ?? "h2"} style={p.style ?? undefined}>
{p.text}
</EmailHeading>
);
}
function TextComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Text">>) {
const p = element.props;
return <EmailText style={p.style ?? undefined}>{p.text}</EmailText>;
}
function LinkComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Link">>) {
const p = element.props;
return (
<EmailLink href={p.href} style={p.style ?? undefined}>
{p.text}
</EmailLink>
);
}
function ButtonComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Button">>) {
const p = element.props;
return (
<EmailButton href={p.href} style={p.style ?? undefined}>
{p.text}
</EmailButton>
);
}
function ImageComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Image">>) {
const p = element.props;
return (
<EmailImg
src={p.src}
alt={p.alt ?? undefined}
width={p.width ?? undefined}
height={p.height ?? undefined}
style={p.style ?? undefined}
/>
);
}
function HrComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Hr">>) {
const p = element.props;
return <EmailHr style={p.style ?? undefined} />;
}
// =============================================================================
// Utility Components
// =============================================================================
function PreviewComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Preview">>) {
const p = element.props;
return <EmailPreview>{p.text}</EmailPreview>;
}
function MarkdownComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Markdown">>) {
const p = element.props;
return (
<EmailMarkdown
markdownContainerStyles={p.markdownContainerStyles ?? undefined}
markdownCustomStyles={p.markdownCustomStyles ?? undefined}
>
{p.content}
</EmailMarkdown>
);
}
// =============================================================================
// Registry
// =============================================================================
export const standardComponents: ComponentRegistry = {
Html: HtmlComponent,
Head: HeadComponent,
Body: BodyComponent,
Container: ContainerComponent,
Section: SectionComponent,
Row: RowComponent,
Column: ColumnComponent,
Heading: HeadingComponent,
Text: TextComponent,
Link: LinkComponent,
Button: ButtonComponent,
Image: ImageComponent,
Hr: HrComponent,
Preview: PreviewComponent,
Markdown: MarkdownComponent,
};
@@ -0,0 +1,281 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import {
resolveAction,
executeAction,
type ActionBinding,
type ActionHandler,
type ActionConfirm,
type ResolvedAction,
} from "@json-render/core";
import { useStateStore } from "./state";
function generateUniqueId(): string {
return crypto.randomUUID();
}
function deepResolveValue(
value: unknown,
get: (path: string) => unknown,
): unknown {
if (value === null || value === undefined) return value;
if (value === "$id") {
return generateUniqueId();
}
if (typeof value === "object" && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj);
if (keys.length === 1 && typeof obj.$state === "string") {
return get(obj.$state as string);
}
if (keys.length === 1 && "$id" in obj) {
return generateUniqueId();
}
}
if (Array.isArray(value)) {
return value.map((item) => deepResolveValue(item, get));
}
if (typeof value === "object") {
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
resolved[key] = deepResolveValue(val, get);
}
return resolved;
}
return value;
}
export interface PendingConfirmation {
action: ResolvedAction;
handler: ActionHandler;
resolve: () => void;
reject: () => void;
}
export interface ActionContextValue {
handlers: Record<string, ActionHandler>;
loadingActions: Set<string>;
pendingConfirmation: PendingConfirmation | null;
execute: (binding: ActionBinding) => Promise<void>;
confirm: () => void;
cancel: () => void;
registerHandler: (name: string, handler: ActionHandler) => void;
}
const ActionContext = createContext<ActionContextValue | null>(null);
export interface ActionProviderProps {
handlers?: Record<string, ActionHandler>;
navigate?: (path: string) => void;
children: ReactNode;
}
export function ActionProvider({
handlers: initialHandlers = {},
navigate,
children,
}: ActionProviderProps) {
const { state, get, set } = useStateStore();
const [handlers, setHandlers] =
useState<Record<string, ActionHandler>>(initialHandlers);
const [loadingActions, setLoadingActions] = useState<Set<string>>(new Set());
const [pendingConfirmation, setPendingConfirmation] =
useState<PendingConfirmation | null>(null);
const registerHandler = useCallback(
(name: string, handler: ActionHandler) => {
setHandlers((prev) => ({ ...prev, [name]: handler }));
},
[],
);
const execute = useCallback(
async (binding: ActionBinding) => {
const resolved = resolveAction(binding, state);
if (resolved.action === "setState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const value = resolved.params.value;
if (statePath) {
set(statePath, value);
}
return;
}
if (resolved.action === "pushState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const rawValue = resolved.params.value;
if (statePath) {
const resolvedValue = deepResolveValue(rawValue, get);
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(statePath, [...arr, resolvedValue]);
const clearStatePath = resolved.params.clearStatePath as
| string
| undefined;
if (clearStatePath) {
set(clearStatePath, "");
}
}
return;
}
if (resolved.action === "removeState" && resolved.params) {
const statePath = resolved.params.statePath as string;
const index = resolved.params.index as number;
if (statePath !== undefined && index !== undefined) {
const arr = (get(statePath) as unknown[] | undefined) ?? [];
set(
statePath,
arr.filter((_, i) => i !== index),
);
}
return;
}
const handler = handlers[resolved.action];
if (!handler) {
console.warn(`No handler registered for action: ${resolved.action}`);
return;
}
if (resolved.confirm) {
return new Promise<void>((resolve, reject) => {
setPendingConfirmation({
action: resolved,
handler,
resolve: () => {
setPendingConfirmation(null);
resolve();
},
reject: () => {
setPendingConfirmation(null);
reject(new Error("Action cancelled"));
},
});
}).then(async () => {
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
});
}
setLoadingActions((prev) => new Set(prev).add(resolved.action));
try {
await executeAction({
action: resolved,
handler,
setState: set,
navigate,
executeAction: async (name) => {
const subBinding: ActionBinding = { action: name };
await execute(subBinding);
},
});
} finally {
setLoadingActions((prev) => {
const next = new Set(prev);
next.delete(resolved.action);
return next;
});
}
},
[state, handlers, get, set, navigate],
);
const confirm = useCallback(() => {
pendingConfirmation?.resolve();
}, [pendingConfirmation]);
const cancel = useCallback(() => {
pendingConfirmation?.reject();
}, [pendingConfirmation]);
const value = useMemo<ActionContextValue>(
() => ({
handlers,
loadingActions,
pendingConfirmation,
execute,
confirm,
cancel,
registerHandler,
}),
[
handlers,
loadingActions,
pendingConfirmation,
execute,
confirm,
cancel,
registerHandler,
],
);
return (
<ActionContext.Provider value={value}>{children}</ActionContext.Provider>
);
}
export function useActions(): ActionContextValue {
const ctx = useContext(ActionContext);
if (!ctx) {
throw new Error("useActions must be used within an ActionProvider");
}
return ctx;
}
export function useAction(binding: ActionBinding): {
execute: () => Promise<void>;
isLoading: boolean;
} {
const { execute, loadingActions } = useActions();
const isLoading = loadingActions.has(binding.action);
const executeAction = useCallback(() => execute(binding), [execute, binding]);
return { execute: executeAction, isLoading };
}
export interface ConfirmDialogProps {
confirm: ActionConfirm;
onConfirm: () => void;
onCancel: () => void;
}
/**
* No-op confirm dialog for email context. Emails are non-interactive,
* so confirmations are not rendered.
*/
export function ConfirmDialog(_props: ConfirmDialogProps) {
return null;
}
@@ -0,0 +1,26 @@
import React, { createContext, useContext, type ReactNode } from "react";
export interface RepeatScopeValue {
item: unknown;
index: number;
basePath: string;
}
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
export function RepeatScopeProvider({
item,
index,
basePath,
children,
}: RepeatScopeValue & { children: ReactNode }) {
return (
<RepeatScopeContext.Provider value={{ item, index, basePath }}>
{children}
</RepeatScopeContext.Provider>
);
}
export function useRepeatScope(): RepeatScopeValue | null {
return useContext(RepeatScopeContext);
}
+117
View File
@@ -0,0 +1,117 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
useRef,
type ReactNode,
} from "react";
import { getByPath, setByPath, type StateModel } from "@json-render/core";
export interface StateContextValue {
state: StateModel;
get: (path: string) => unknown;
set: (path: string, value: unknown) => void;
update: (updates: Record<string, unknown>) => void;
}
const StateContext = createContext<StateContextValue | null>(null);
export interface StateProviderProps {
initialState?: StateModel;
onStateChange?: (path: string, value: unknown) => void;
children: ReactNode;
}
export function StateProvider({
initialState = {},
onStateChange,
children,
}: StateProviderProps) {
const [state, setStateInternal] = useState<StateModel>(initialState);
const stateRef = useRef(state);
stateRef.current = state;
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
useEffect(() => {
const newJson = JSON.stringify(initialState);
if (newJson !== initialStateJsonRef.current) {
initialStateJsonRef.current = newJson;
if (initialState && Object.keys(initialState).length > 0) {
setStateInternal((prev) => ({ ...prev, ...initialState }));
}
}
}, [initialState]);
const get = useCallback(
(path: string) => getByPath(stateRef.current, path),
[],
);
const set = useCallback(
(path: string, value: unknown) => {
setStateInternal((prev) => {
const next = { ...prev };
setByPath(next, path, value);
return next;
});
onStateChange?.(path, value);
},
[onStateChange],
);
const update = useCallback(
(updates: Record<string, unknown>) => {
const entries = Object.entries(updates);
setStateInternal((prev) => {
const next = { ...prev };
for (const [path, value] of entries) {
setByPath(next, path, value);
}
return next;
});
for (const [path, value] of entries) {
onStateChange?.(path, value);
}
},
[onStateChange],
);
const value = useMemo<StateContextValue>(
() => ({ state, get, set, update }),
[state, get, set, update],
);
return (
<StateContext.Provider value={value}>{children}</StateContext.Provider>
);
}
export function useStateStore(): StateContextValue {
const ctx = useContext(StateContext);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
export function useStateValue<T>(path: string): T | undefined {
const { state } = useStateStore();
return getByPath(state, path) as T | undefined;
}
export function useStateBinding<T>(
path: string,
): [T | undefined, (value: T) => void] {
const { state, set } = useStateStore();
const value = getByPath(state, path) as T | undefined;
const setValue = useCallback(
(newValue: T) => set(path, newValue),
[path, set],
);
return [value, setValue];
}
@@ -0,0 +1,262 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
const ValidationContext = createContext<ValidationContextValue | null>(null);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
children: ReactNode;
}
function dynamicArgsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const va = a[key];
const vb = b[key];
if (va === vb) continue;
if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
const sa = (va as Record<string, unknown>).$state;
const sb = (vb as Record<string, unknown>).$state;
if (typeof sa === "string" && sa === sb) continue;
}
return false;
}
return true;
}
function validationConfigEqual(
a: ValidationConfig,
b: ValidationConfig,
): boolean {
if (a === b) return true;
if (a.validateOn !== b.validateOn) return false;
const ac = a.checks ?? [];
const bc = b.checks ?? [];
if (ac.length !== bc.length) return false;
for (let i = 0; i < ac.length; i++) {
const ca = ac[i]!;
const cb = bc[i]!;
if (ca.type !== cb.type) return false;
if (ca.message !== cb.message) return false;
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
}
return true;
}
export function ValidationProvider({
customFunctions = {},
children,
}: ValidationProviderProps) {
const { state } = useStateStore();
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig>
>({});
const registerField = useCallback(
(path: string, config: ValidationConfig) => {
setFieldConfigs((prev) => {
const existing = prev[path];
if (existing && validationConfigEqual(existing, config)) {
return prev;
}
return { ...prev, [path]: config };
});
},
[],
);
const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => {
const segments = path.split("/").filter(Boolean);
let value: unknown = state;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: state,
customFunctions,
});
setFieldStates((prev) => ({
...prev,
[path]: {
touched: prev[path]?.touched ?? true,
validated: true,
result,
},
}));
return result;
},
[state, customFunctions],
);
const touch = useCallback((path: string) => {
setFieldStates((prev) => ({
...prev,
[path]: {
...prev[path],
touched: true,
validated: prev[path]?.validated ?? false,
result: prev[path]?.result ?? null,
},
}));
}, []);
const clear = useCallback((path: string) => {
setFieldStates((prev) => {
const { [path]: _, ...rest } = prev;
return rest;
});
}, []);
const validateAll = useCallback(() => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs)) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
}, [fieldConfigs, validate]);
const value = useMemo<ValidationContextValue>(
() => ({
customFunctions,
fieldStates,
validate,
touch,
clear,
validateAll,
registerField,
}),
[
customFunctions,
fieldStates,
validate,
touch,
clear,
validateAll,
registerField,
],
);
return (
<ValidationContext.Provider value={value}>
{children}
</ValidationContext.Provider>
);
}
export function useValidation(): ValidationContextValue {
const ctx = useContext(ValidationContext);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: FieldValidationState;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: string[];
isValid: boolean;
} {
const {
fieldStates,
validate: validateField,
touch: touchField,
clear: clearField,
registerField,
} = useValidation();
React.useEffect(() => {
if (config) {
registerField(path, config);
}
}, [path, config, registerField]);
const state = fieldStates[path] ?? {
touched: false,
validated: false,
result: null,
};
const validate = useCallback(
() => validateField(path, config ?? { checks: [] }),
[path, config, validateField],
);
const touch = useCallback(() => touchField(path), [path, touchField]);
const clear = useCallback(() => clearField(path), [path, clearField]);
return {
state,
validate,
touch,
clear,
errors: state.result?.errors ?? [],
isValid: state.result?.valid ?? true,
};
}
@@ -0,0 +1,64 @@
import React, {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface VisibilityContextValue {
isVisible: (condition: VisibilityCondition | undefined) => boolean;
ctx: CoreVisibilityContext;
}
const VisibilityContext = createContext<VisibilityContextValue | null>(null);
export interface VisibilityProviderProps {
children: ReactNode;
}
export function VisibilityProvider({ children }: VisibilityProviderProps) {
const { state } = useStateStore();
const ctx: CoreVisibilityContext = useMemo(
() => ({ stateModel: state }),
[state],
);
const isVisible = useMemo(
() => (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, ctx),
[ctx],
);
const value = useMemo<VisibilityContextValue>(
() => ({ isVisible, ctx }),
[isVisible, ctx],
);
return (
<VisibilityContext.Provider value={value}>
{children}
</VisibilityContext.Provider>
);
}
export function useVisibility(): VisibilityContextValue {
const ctx = useContext(VisibilityContext);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
export function useIsVisible(
condition: VisibilityCondition | undefined,
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
+87
View File
@@ -0,0 +1,87 @@
// Schema
export { schema, type ReactEmailSchema, type ReactEmailSpec } from "./schema";
// Core types (re-exported for convenience)
export type { Spec } from "@json-render/core";
// Catalog-aware types
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
// Contexts
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
type StateContextValue,
type StateProviderProps,
} from "./contexts/state";
export {
VisibilityProvider,
useVisibility,
useIsVisible,
type VisibilityContextValue,
type VisibilityProviderProps,
} from "./contexts/visibility";
export {
ActionProvider,
useActions,
useAction,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./contexts/actions";
export {
ValidationProvider,
useValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./contexts/validation";
export {
RepeatScopeProvider,
useRepeatScope,
type RepeatScopeValue,
} from "./contexts/repeat-scope";
// Renderer
export {
defineRegistry,
type DefineRegistryResult,
createRenderer,
type CreateRendererProps,
type ComponentMap,
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
// Standard components
export { standardComponents } from "./components";
// Server-side render functions
export { renderToHtml, renderToPlainText, type RenderOptions } from "./render";
// Catalog definitions
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
+407
View File
@@ -0,0 +1,407 @@
import { describe, it, expect } from "vitest";
import type { Spec } from "@json-render/core";
import { renderToHtml, renderToPlainText } from "./render";
import { examples } from "./__fixtures__/examples";
// =============================================================================
// Helpers
// =============================================================================
/** Minimal valid email spec — Html > Head + Body */
function minimalSpec(bodyChildren: Spec["elements"] = {}): Spec {
return {
root: "html",
elements: {
html: {
type: "Html",
props: { lang: "en", dir: null },
children: ["head", "body"],
},
head: { type: "Head", props: {}, children: [] },
body: {
type: "Body",
props: { style: null },
children: Object.keys(bodyChildren),
},
...bodyChildren,
},
};
}
function validateSpecStructure(spec: Spec) {
const errors: string[] = [];
if (!spec.elements[spec.root]) {
errors.push(`Root element "${spec.root}" not found in elements`);
}
for (const [id, element] of Object.entries(spec.elements)) {
if (element.children) {
for (const childId of element.children) {
if (!spec.elements[childId]) {
errors.push(
`Element "${id}" references child "${childId}" which does not exist`,
);
}
}
}
}
const referencedIds = new Set<string>();
referencedIds.add(spec.root);
for (const element of Object.values(spec.elements)) {
if (element.children) {
for (const childId of element.children) {
referencedIds.add(childId);
}
}
}
for (const id of Object.keys(spec.elements)) {
if (!referencedIds.has(id)) {
errors.push(`Element "${id}" is orphaned (not referenced by any parent)`);
}
}
return errors;
}
// =============================================================================
// renderToHtml
// =============================================================================
describe("renderToHtml", () => {
it("renders a minimal spec to valid HTML", async () => {
const html = await renderToHtml(minimalSpec());
expect(html).toContain("<!DOCTYPE");
expect(html).toContain("<html");
expect(html).toContain("</html>");
});
it("renders Text component content", async () => {
const spec = minimalSpec({
text: {
type: "Text",
props: { text: "Hello World", style: null },
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("Hello World");
});
it("renders Heading with correct tag level", async () => {
const spec = minimalSpec({
heading: {
type: "Heading",
props: { text: "Title", as: "h1", style: null },
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("<h1");
expect(html).toContain("Title");
});
it("renders Link with href", async () => {
const spec = minimalSpec({
link: {
type: "Link",
props: {
text: "Click here",
href: "https://example.com",
style: null,
},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("https://example.com");
expect(html).toContain("Click here");
});
it("renders Button with href and text", async () => {
const spec = minimalSpec({
btn: {
type: "Button",
props: {
text: "Get Started",
href: "https://example.com/start",
style: null,
},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("https://example.com/start");
expect(html).toContain("Get Started");
});
it("renders Image with src and alt", async () => {
const spec = minimalSpec({
img: {
type: "Image",
props: {
src: "https://example.com/logo.png",
alt: "Logo",
width: 100,
height: 50,
style: null,
},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("https://example.com/logo.png");
expect(html).toContain('alt="Logo"');
});
it("renders Hr element", async () => {
const spec = minimalSpec({
divider: {
type: "Hr",
props: { style: { borderColor: "#eaeaea" } },
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("<hr");
});
it("renders Preview text", async () => {
const spec: Spec = {
root: "html",
elements: {
html: {
type: "Html",
props: { lang: "en", dir: null },
children: ["head", "preview", "body"],
},
head: { type: "Head", props: {}, children: [] },
preview: {
type: "Preview",
props: { text: "Inbox preview text" },
children: [],
},
body: { type: "Body", props: { style: null }, children: [] },
},
};
const html = await renderToHtml(spec);
expect(html).toContain("Inbox preview text");
});
it("renders nested Container > Section > Text", async () => {
const spec = minimalSpec({
container: {
type: "Container",
props: { style: { maxWidth: "600px" } },
children: ["section"],
},
section: {
type: "Section",
props: { style: null },
children: ["text"],
},
text: {
type: "Text",
props: { text: "Nested content", style: null },
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("Nested content");
});
it("renders Row and Column layout", async () => {
const spec = minimalSpec({
section: {
type: "Section",
props: { style: null },
children: ["row"],
},
row: {
type: "Row",
props: { style: null },
children: ["col1", "col2"],
},
col1: {
type: "Column",
props: { style: { width: "50%" } },
children: ["left-text"],
},
col2: {
type: "Column",
props: { style: { width: "50%" } },
children: ["right-text"],
},
"left-text": {
type: "Text",
props: { text: "Left side", style: null },
children: [],
},
"right-text": {
type: "Text",
props: { text: "Right side", style: null },
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("Left side");
expect(html).toContain("Right side");
});
it("renders Markdown content", async () => {
const spec = minimalSpec({
md: {
type: "Markdown",
props: {
content: "# Hello\n\nThis is **bold** text.",
markdownContainerStyles: null,
markdownCustomStyles: null,
},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("Hello");
expect(html).toContain("bold");
});
it("applies inline styles", async () => {
const spec = minimalSpec({
text: {
type: "Text",
props: {
text: "Styled",
style: { color: "#ff0000", fontSize: "20px" },
},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("color:#ff0000");
expect(html).toContain("font-size:20px");
});
it("skips unknown component types gracefully", async () => {
const spec = minimalSpec({
unknown: {
type: "NonExistentComponent",
props: {},
children: [],
},
});
const html = await renderToHtml(spec);
expect(html).toContain("<!DOCTYPE");
});
it("handles missing child elements gracefully", async () => {
const spec: Spec = {
root: "html",
elements: {
html: {
type: "Html",
props: { lang: "en", dir: null },
children: ["head", "body"],
},
head: { type: "Head", props: {}, children: [] },
body: {
type: "Body",
props: { style: null },
children: ["missing-child"],
},
},
};
const html = await renderToHtml(spec);
expect(html).toContain("<!DOCTYPE");
});
});
// =============================================================================
// renderToPlainText
// =============================================================================
describe("renderToPlainText", () => {
it("extracts text content from spec", async () => {
const spec = minimalSpec({
text: {
type: "Text",
props: { text: "Plain text content", style: null },
children: [],
},
});
const text = await renderToPlainText(spec);
expect(text).toContain("Plain text content");
});
it("extracts link text and URL", async () => {
const spec = minimalSpec({
link: {
type: "Link",
props: {
text: "Visit site",
href: "https://example.com",
style: null,
},
children: [],
},
});
const text = await renderToPlainText(spec);
expect(text).toContain("Visit site");
expect(text).toContain("https://example.com");
});
it("returns string for minimal spec", async () => {
const text = await renderToPlainText(minimalSpec());
expect(typeof text).toBe("string");
});
});
// =============================================================================
// Example specs — structural validation + render smoke tests
// =============================================================================
describe("example specs", () => {
it("has at least one example", () => {
expect(examples.length).toBeGreaterThan(0);
});
for (const example of examples) {
describe(example.name, () => {
it("has required metadata", () => {
expect(example.name).toBeTruthy();
expect(example.label).toBeTruthy();
expect(example.description).toBeTruthy();
});
it("has a valid spec structure (no dangling children, no orphans)", () => {
const errors = validateSpecStructure(example.spec);
expect(errors).toEqual([]);
});
it("root element is Html with Head and Body children", () => {
const root = example.spec.elements[example.spec.root];
expect(root).toBeDefined();
expect(root!.type).toBe("Html");
const childTypes = root!.children?.map(
(id) => example.spec.elements[id]?.type,
);
expect(childTypes).toContain("Head");
expect(childTypes).toContain("Body");
});
it("renders to HTML without errors", async () => {
const html = await renderToHtml(example.spec);
expect(html).toBeTruthy();
expect(html).toContain("<!DOCTYPE");
expect(html).toContain("</html>");
});
it("renders to plain text without errors", async () => {
const text = await renderToPlainText(example.spec);
expect(text).toBeTruthy();
expect(typeof text).toBe("string");
});
});
}
});
+168
View File
@@ -0,0 +1,168 @@
import React from "react";
import { render } from "@react-email/render";
import type { Spec, UIElement } from "@json-render/core";
import {
resolveElementProps,
evaluateVisibility,
getByPath,
type PropResolutionContext,
} from "@json-render/core";
import { standardComponents } from "./components/standard";
// Re-export the standard components for use in custom registries
export { standardComponents };
export type RenderComponentRegistry = Record<string, React.ComponentType<any>>;
export interface RenderOptions {
registry?: RenderComponentRegistry;
includeStandard?: boolean;
state?: Record<string, unknown>;
}
const noopEmit = () => {};
function renderElement(
elementKey: string,
spec: Spec,
registry: RenderComponentRegistry,
stateModel: Record<string, unknown>,
repeatItem?: unknown,
repeatIndex?: number,
repeatBasePath?: string,
): React.ReactElement | null {
const element = spec.elements[elementKey];
if (!element) return null;
const ctx: PropResolutionContext = {
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
};
if (element.visible !== undefined) {
if (!evaluateVisibility(element.visible, ctx)) {
return null;
}
}
const resolvedProps = resolveElementProps(
element.props as Record<string, unknown>,
ctx,
);
const resolvedElement: UIElement = { ...element, props: resolvedProps };
const Component = registry[resolvedElement.type];
if (!Component) return null;
if (resolvedElement.repeat) {
const items =
(getByPath(stateModel, resolvedElement.repeat.statePath) as
| unknown[]
| undefined) ?? [];
const repeat = resolvedElement.repeat!;
const fragments = items.map((item, index) => {
const repeatKey = repeat.key;
const key =
repeatKey && typeof item === "object" && item !== null
? String((item as Record<string, unknown>)[repeatKey] ?? index)
: String(index);
const childPath = `${repeat.statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
item,
index,
childPath,
),
);
return (
<Component key={key} element={resolvedElement} emit={noopEmit}>
{children}
</Component>
);
});
return <>{fragments}</>;
}
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
),
);
return (
<Component key={elementKey} element={resolvedElement} emit={noopEmit}>
{children && children.length > 0 ? children : undefined}
</Component>
);
}
function buildDocument(
spec: Spec,
options: RenderOptions = {},
): React.ReactElement {
const {
registry: customRegistry,
includeStandard = true,
state = {},
} = options;
const mergedState: Record<string, unknown> = {
...spec.state,
...state,
};
const registry: RenderComponentRegistry = {
...(includeStandard ? standardComponents : {}),
...customRegistry,
};
const root = renderElement(spec.root, spec, registry, mergedState);
if (!root) {
console.warn(
`[json-render/react-email] Root element "${spec.root}" not found in spec.elements`,
);
}
return root ?? <></>;
}
/**
* Render a json-render spec to an HTML email string.
*
* This is a standalone server-side function that resolves the spec tree
* without React hooks or contexts, making it safe to import in Next.js
* route handlers and other server-only environments.
*/
export async function renderToHtml(
spec: Spec,
options?: RenderOptions,
): Promise<string> {
const document = buildDocument(spec, options);
return render(document);
}
/**
* Render a json-render spec to a plain text email string.
*/
export async function renderToPlainText(
spec: Spec,
options?: RenderOptions,
): Promise<string> {
const document = buildDocument(spec, options);
return render(document, { plainText: true });
}
+502
View File
@@ -0,0 +1,502 @@
import React, {
type ComponentType,
type ErrorInfo,
type ReactNode,
useCallback,
useMemo,
} from "react";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
SchemaDefinition,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import type { Components, SetState, StateModel } from "./catalog-types";
import { useIsVisible, useVisibility } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useStateStore } from "./contexts/state";
import { StateProvider } from "./contexts/state";
import { VisibilityProvider } from "./contexts/visibility";
import { ActionProvider } from "./contexts/actions";
import { ValidationProvider } from "./contexts/validation";
import { standardComponents } from "./components/standard";
import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
// =============================================================================
// Types
// =============================================================================
export interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}
export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
ComponentRenderProps<P>
>;
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
export interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean;
loading?: boolean;
fallback?: ComponentRenderer;
}
// =============================================================================
// ElementErrorBoundary
// =============================================================================
interface ElementErrorBoundaryProps {
elementType: string;
children: ReactNode;
}
interface ElementErrorBoundaryState {
hasError: boolean;
}
class ElementErrorBoundary extends React.Component<
ElementErrorBoundaryProps,
ElementErrorBoundaryState
> {
constructor(props: ElementErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ElementErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(
`[json-render/react-email] Rendering error in <${this.props.elementType}>:`,
error,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
// =============================================================================
// ElementRenderer
// =============================================================================
interface ElementRendererProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
const ElementRenderer = React.memo(function ElementRenderer({
element,
spec,
registry,
loading,
fallback,
}: ElementRendererProps) {
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const fullCtx: PropResolutionContext = useMemo(
() =>
repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: ctx,
[ctx, repeatScope],
);
const isVisible =
element.visible === undefined
? true
: evaluateVisibility(element.visible, fullCtx);
const onBindings = element.on;
const emit = useCallback(
(eventName: string) => {
const binding = onBindings?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
execute(b);
continue;
}
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, fullCtx);
}
execute({ ...b, params: resolved });
}
},
[onBindings, execute, fullCtx],
);
if (!isVisible) {
return null;
}
const rawProps = element.props as Record<string, unknown>;
const elementBindings = resolveBindings(rawProps, fullCtx);
const resolvedProps = resolveElementProps(rawProps, fullCtx);
const resolvedElement =
resolvedProps !== element.props
? { ...element, props: resolvedProps }
: element;
const Component = registry[resolvedElement.type] ?? fallback;
if (!Component) {
console.warn(
`[json-render/react-email] No renderer for component type: ${resolvedElement.type}`,
);
return null;
}
const children = resolvedElement.repeat ? (
<RepeatChildren
element={resolvedElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
) : (
resolvedElement.children?.map((childKey) => {
const childElement = spec.elements[childKey];
if (!childElement) {
if (!loading) {
console.warn(
`[json-render/react-email] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
);
}
return null;
}
return (
<ElementRenderer
key={childKey}
element={childElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
})
);
return (
<ElementErrorBoundary elementType={resolvedElement.type}>
<Component
element={resolvedElement}
emit={emit}
bindings={elementBindings}
loading={loading}
>
{children}
</Component>
</ElementErrorBoundary>
);
});
// =============================================================================
// RepeatChildren
// =============================================================================
function RepeatChildren({
element,
spec,
registry,
loading,
fallback,
}: {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}) {
const { state } = useStateStore();
const repeat = element.repeat!;
const statePath = repeat.statePath;
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
return (
<>
{items.map((itemValue, index) => {
const key =
repeat.key && typeof itemValue === "object" && itemValue !== null
? String(
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
)
: String(index);
return (
<RepeatScopeProvider
key={key}
item={itemValue}
index={index}
basePath={`${statePath}/${index}`}
>
{element.children?.map((childKey) => {
const childElement = spec.elements[childKey];
if (!childElement) {
if (!loading) {
console.warn(
`[json-render/react-email] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
);
}
return null;
}
return (
<ElementRenderer
key={childKey}
element={childElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
})}
</RepeatScopeProvider>
);
})}
</>
);
}
// =============================================================================
// Renderer
// =============================================================================
export function Renderer({
spec,
registry: customRegistry,
includeStandard = true,
loading,
fallback,
}: RendererProps) {
const registry: ComponentRegistry = useMemo(
() => ({
...(includeStandard ? standardComponents : {}),
...customRegistry,
}),
[customRegistry, includeStandard],
);
if (!spec || !spec.root) {
return null;
}
const rootElement = spec.elements[spec.root];
if (!rootElement) {
return null;
}
return (
<ElementRenderer
element={rootElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
}
// =============================================================================
// JSONUIProvider
// =============================================================================
export interface JSONUIProviderProps {
initialState?: Record<string, unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate?: (path: string) => void;
validationFunctions?: Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
onStateChange?: (path: string, value: unknown) => void;
children: ReactNode;
}
export function JSONUIProvider({
initialState,
handlers,
navigate,
validationFunctions,
onStateChange,
children,
}: JSONUIProviderProps) {
return (
<StateProvider initialState={initialState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider handlers={handlers} navigate={navigate}>
<ValidationProvider customFunctions={validationFunctions}>
{children}
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
// =============================================================================
// defineRegistry
// =============================================================================
export interface DefineRegistryResult {
registry: ComponentRegistry;
}
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}) => React.ReactNode;
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
},
): DefineRegistryResult {
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
emit,
bindings,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
emit,
bindings,
loading,
});
};
}
}
return { registry };
}
// =============================================================================
// createRenderer
// =============================================================================
export interface CreateRendererProps {
spec: Spec | null;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (path: string, value: unknown) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
export type ComponentMap<
TComponents extends Record<string, { props: unknown }>,
> = {
[K in keyof TComponents]: ComponentType<
ComponentRenderProps<
TComponents[K]["props"] extends { _output: infer O }
? O
: Record<string, unknown>
>
>;
};
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): ComponentType<CreateRendererProps> {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
return function CatalogRenderer({
spec,
state,
onAction,
onStateChange,
loading,
fallback,
}: CreateRendererProps) {
const actionHandlers = onAction
? new Proxy(
{} as Record<
string,
(params: Record<string, unknown>) => void | Promise<void>
>,
{
get: (_target, prop: string) => {
return (params: Record<string, unknown>) =>
onAction(prop, params);
},
has: () => true,
},
)
: undefined;
return (
<StateProvider initialState={state} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
};
}
+57
View File
@@ -0,0 +1,57 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/react-email
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas
*
* Reuses the same { root, elements } spec format as the React and React Native renderers.
*/
export const schema = defineSchema(
(s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
}),
}),
{
defaultRules: [
"The root element MUST be an Html component. Its children MUST include Head and Body components.",
"Body should contain a Container component to constrain width (typically 600px max for email clients).",
"All styles MUST be inline. Email clients strip <style> tags, so every component that accepts a style prop should use it for visual customization.",
"Image src must be a fully qualified URL (absolute, not relative). For placeholder images use https://picsum.photos/{width}/{height}?random={n}.",
"Emails are static documents. There are no interactive actions or form inputs.",
"Use Section, Row, and Column for layout. These map to table-based email structures for maximum compatibility.",
"Use Preview to set the preview text shown in email client inboxes before opening.",
"Use Heading (h1-h6) and Text for all text content. Raw strings are not supported.",
"Button renders as a link styled as a button. Always provide both text and href.",
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
],
},
);
export type ReactEmailSchema = typeof schema;
export type ReactEmailSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
+20
View File
@@ -0,0 +1,20 @@
// Server-safe entry point: schema and catalog definitions only.
// Does not import React or @react-email/components.
export { schema, type ReactEmailSchema, type ReactEmailSpec } from "./schema";
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
export type { Spec } from "@json-render/core";
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@internal/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: [
"react",
"@json-render/core",
"@react-email/components",
"@react-email/render",
"zod",
],
});
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-native
## 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/react-native",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-pdf
## 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/react-pdf",
"version": "0.10.0",
"version": "0.11.0",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @internal/react-state
## 0.8.4
### Patch Changes
- Updated dependencies [3f1e71e]
- @json-render/core@0.11.0
## 0.8.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@internal/react-state",
"version": "0.8.3",
"version": "0.8.4",
"private": true,
"license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages",

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