Compare commits

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

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

* fix: remove portless dependency

* fix: revert changes to pnpm-lock.yml

* revert again after pulling latest changes
2026-03-02 11:43:55 -06:00
William Manzoli 5177ae8472 Merge branch 'main' into feat/react-email-examples 2026-03-01 01:16:07 -03:00
William Manzoli 72174a0453 Merge branch 'main' into feat/react-email-examples 2026-02-25 12:55:11 -03:00
Lucian Fialho 39149594e0 Merge branch 'main' into feat/react-email-examples 2026-02-25 07:40:35 -03:00
Lucian Fialho c034e2dbcb fix PR review feedback
- fix tsconfig: remove rootDir, expand include for test imports
- fix render tests: add non-null assertions for noUncheckedIndexedAccess
- fix dev script: add portless to match other examples
- fix .env.example: correct comment from video to email generation
- fix docs: add blank line before heading in installation page
2026-02-25 07:36:31 -03:00
Lucian Fialho 2b86d50b57 fix @internal/react-state alias in vitest config
Pre-existing issue: vitest could not resolve @internal/react-state,
causing 6 test suites in packages/react and packages/react-pdf to fail.
2026-02-25 07:36:24 -03:00
Lucian Fialho fc841d7bb9 Merge branch 'main' into feat/react-email-examples 2026-02-24 21:52:42 -03:00
WManzoli 0770687bbd add json render react email skill 2026-02-24 21:25:49 -03:00
WManzoli 282fe6138f add react email documentation 2026-02-24 20:24:24 -03:00
Lucian Fialho 0a5cbb4529 fix missing style prop on stripe-welcome logo Image 2026-02-24 19:03:56 -03:00
Lucian Fialho 6a48ad8608 add render tests for react-email package 2026-02-24 14:42:29 -03:00
Lucian Fialho 3aa8b392dc add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt 2026-02-24 14:42:18 -03:00
Lucian Fialho 1de2b380ef add react-email package 2026-02-24 14:41:57 -03:00
Lucian Fialho 4afbb433a1 add react-email workspace dependencies 2026-02-24 14:41:39 -03:00
69 changed files with 7343 additions and 3 deletions
+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",
+38
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,7 @@ 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` |
@@ -288,6 +291,40 @@ 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
@@ -422,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`
@@ -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:
+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/image, @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.
+6
View File
@@ -65,6 +65,11 @@ 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",
@@ -110,6 +115,7 @@ 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" },
+1
View File
@@ -43,6 +43,7 @@ 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",
+1
View File
@@ -5,6 +5,7 @@
"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",
+1
View File
@@ -4,6 +4,7 @@
"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",
+1
View File
@@ -4,6 +4,7 @@
"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",
+1
View File
@@ -4,6 +4,7 @@
"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"]
}
+1
View File
@@ -4,6 +4,7 @@
"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",
+1
View File
@@ -4,6 +4,7 @@
"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
View File
@@ -3,6 +3,7 @@
"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"
+1
View File
@@ -3,6 +3,7 @@
"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}\"",
+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",
],
});
+528 -2
View File
@@ -584,6 +584,79 @@ importers:
specifier: ^5.7.2
version: 5.9.2
examples/react-email:
dependencies:
'@ai-sdk/gateway':
specifier: ^3.0.52
version: 3.0.57(zod@4.3.5)
'@json-render/core':
specifier: workspace:*
version: link:../../packages/core
'@json-render/react-email':
specifier: workspace:*
version: link:../../packages/react-email
'@tailwindcss/postcss':
specifier: ^4.1.18
version: 4.1.18
ai:
specifier: 6.0.94
version: 6.0.94(zod@4.3.5)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^0.575.0
version: 0.575.0(react@19.2.4)
next:
specifier: 16.1.6
version: 16.1.6(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.2.4)
react-resizable-panels:
specifier: ^4.4.1
version: 4.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
tailwindcss:
specifier: ^4.1.18
version: 4.1.18
zod:
specifier: 4.3.5
version: 4.3.5
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../../packages/typescript-config
'@types/node':
specifier: ^22.10.0
version: 22.19.6
'@types/react':
specifier: 19.2.3
version: 19.2.3
'@types/react-dom':
specifier: 19.2.3
version: 19.2.3(@types/react@19.2.3)
shadcn:
specifier: ^3.8.5
version: 3.8.5(@types/node@22.19.6)(typescript@5.9.2)
tw-animate-css:
specifier: ^1.4.0
version: 1.4.0
typescript:
specifier: ^5.7.2
version: 5.9.2
examples/react-native:
dependencies:
'@ai-sdk/gateway':
@@ -1095,6 +1168,37 @@ importers:
specifier: ^5.4.5
version: 5.9.2
packages/react-email:
dependencies:
'@json-render/core':
specifier: workspace:*
version: link:../core
'@react-email/components':
specifier: ^1.0.8
version: 1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-email/render':
specifier: ^2.0.4
version: 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react:
specifier: ^19.0.0
version: 19.2.4
devDependencies:
'@internal/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/react':
specifier: 19.2.3
version: 19.2.3
tsup:
specifier: ^8.0.2
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
typescript:
specifier: ^5.4.5
version: 5.9.2
zod:
specifier: ^4.0.0
version: 4.3.6
packages/react-native:
dependencies:
'@json-render/core':
@@ -4310,6 +4414,165 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
'@react-email/body@0.2.1':
resolution: {integrity: sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/button@0.2.1':
resolution: {integrity: sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/code-block@0.2.1':
resolution: {integrity: sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/code-inline@0.0.6':
resolution: {integrity: sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/column@0.0.14':
resolution: {integrity: sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/components@1.0.8':
resolution: {integrity: sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/container@0.0.16':
resolution: {integrity: sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/font@0.0.10':
resolution: {integrity: sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/head@0.0.13':
resolution: {integrity: sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/heading@0.0.16':
resolution: {integrity: sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/hr@0.0.12':
resolution: {integrity: sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/html@0.0.12':
resolution: {integrity: sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/img@0.0.12':
resolution: {integrity: sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/link@0.0.13':
resolution: {integrity: sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/markdown@0.0.18':
resolution: {integrity: sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/preview@0.0.14':
resolution: {integrity: sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/render@2.0.4':
resolution: {integrity: sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/row@0.0.13':
resolution: {integrity: sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/section@0.0.17':
resolution: {integrity: sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/tailwind@2.0.5':
resolution: {integrity: sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==}
engines: {node: '>=20.0.0'}
peerDependencies:
'@react-email/body': 0.2.1
'@react-email/button': 0.2.1
'@react-email/code-block': 0.2.1
'@react-email/code-inline': 0.0.6
'@react-email/container': 0.0.16
'@react-email/heading': 0.0.16
'@react-email/hr': 0.0.12
'@react-email/img': 0.0.12
'@react-email/link': 0.0.13
'@react-email/preview': 0.0.14
'@react-email/text': 0.1.6
react: ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@react-email/body':
optional: true
'@react-email/button':
optional: true
'@react-email/code-block':
optional: true
'@react-email/code-inline':
optional: true
'@react-email/container':
optional: true
'@react-email/heading':
optional: true
'@react-email/hr':
optional: true
'@react-email/img':
optional: true
'@react-email/link':
optional: true
'@react-email/preview':
optional: true
'@react-email/text@0.1.6':
resolution: {integrity: sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==}
engines: {node: '>=20.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-native/assets-registry@0.81.4':
resolution: {integrity: sha512-AMcDadefBIjD10BRqkWw+W/VdvXEomR6aEZ0fhQRAv7igrBzb4PTn4vHKYg+sUK0e3wa74kcMy2DLc/HtnGcMA==}
engines: {node: '>= 20.19.4'}
@@ -4928,6 +5191,9 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@selderee/plugin-htmlparser2@0.11.0':
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
'@shikijs/core@3.21.0':
resolution: {integrity: sha512-AXSQu/2n1UIQekY8euBJlvFYZIw0PHY63jUzGbrOma4wPxzznJXTXkri+QcHeBNaFxiiOljKxxJkVSoB3PjbyA==}
@@ -6770,11 +7036,24 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domexception@2.0.1:
resolution: {integrity: sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==}
engines: {node: '>=8'}
deprecated: Use your platform's native DOMException instead
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -6968,6 +7247,10 @@ packages:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -7867,12 +8150,19 @@ packages:
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
html-to-text@9.0.5:
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
engines: {node: '>=14'}
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -8612,6 +8902,9 @@ packages:
resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==}
hasBin: true
leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -8843,6 +9136,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
marked@15.0.12:
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
engines: {node: '>= 18'}
hasBin: true
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
@@ -9553,6 +9851,9 @@ packages:
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -9600,6 +9901,9 @@ packages:
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -9769,6 +10073,10 @@ packages:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
proc-log@4.2.0:
resolution: {integrity: sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -10345,6 +10653,9 @@ packages:
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
engines: {node: '>=4'}
selderee@0.11.0:
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
@@ -11750,6 +12061,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.3.5:
resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -11832,6 +12146,13 @@ snapshots:
'@vercel/oidc': 3.1.0
zod: 4.3.6
'@ai-sdk/gateway@3.0.52(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
'@vercel/oidc': 3.1.0
zod: 4.3.5
'@ai-sdk/gateway@3.0.52(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -11846,6 +12167,13 @@ snapshots:
'@vercel/oidc': 3.1.0
zod: 4.3.6
'@ai-sdk/gateway@3.0.57(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
'@vercel/oidc': 3.1.0
zod: 4.3.5
'@ai-sdk/gateway@3.0.57(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -11867,6 +12195,13 @@ snapshots:
eventsource-parser: 3.0.6
zod: 4.3.6
'@ai-sdk/provider-utils@4.0.15(zod@4.3.5)':
dependencies:
'@ai-sdk/provider': 3.0.8
'@standard-schema/spec': 1.1.0
eventsource-parser: 3.0.6
zod: 4.3.5
'@ai-sdk/provider-utils@4.0.15(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
@@ -15971,6 +16306,130 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
'@react-email/body@0.2.1(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/button@0.2.1(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/code-block@0.2.1(react@19.2.4)':
dependencies:
prismjs: 1.30.0
react: 19.2.4
'@react-email/code-inline@0.0.6(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/column@0.0.14(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/components@1.0.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@react-email/body': 0.2.1(react@19.2.4)
'@react-email/button': 0.2.1(react@19.2.4)
'@react-email/code-block': 0.2.1(react@19.2.4)
'@react-email/code-inline': 0.0.6(react@19.2.4)
'@react-email/column': 0.0.14(react@19.2.4)
'@react-email/container': 0.0.16(react@19.2.4)
'@react-email/font': 0.0.10(react@19.2.4)
'@react-email/head': 0.0.13(react@19.2.4)
'@react-email/heading': 0.0.16(react@19.2.4)
'@react-email/hr': 0.0.12(react@19.2.4)
'@react-email/html': 0.0.12(react@19.2.4)
'@react-email/img': 0.0.12(react@19.2.4)
'@react-email/link': 0.0.13(react@19.2.4)
'@react-email/markdown': 0.0.18(react@19.2.4)
'@react-email/preview': 0.0.14(react@19.2.4)
'@react-email/render': 2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@react-email/row': 0.0.13(react@19.2.4)
'@react-email/section': 0.0.17(react@19.2.4)
'@react-email/tailwind': 2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)
'@react-email/text': 0.1.6(react@19.2.4)
react: 19.2.4
transitivePeerDependencies:
- react-dom
'@react-email/container@0.0.16(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/font@0.0.10(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/head@0.0.13(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/heading@0.0.16(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/hr@0.0.12(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/html@0.0.12(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/img@0.0.12(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/link@0.0.13(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/markdown@0.0.18(react@19.2.4)':
dependencies:
marked: 15.0.12
react: 19.2.4
'@react-email/preview@0.0.14(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/render@2.0.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
html-to-text: 9.0.5
prettier: 3.7.4
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@react-email/row@0.0.13(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/section@0.0.17(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-email/tailwind@2.0.5(@react-email/body@0.2.1(react@19.2.4))(@react-email/button@0.2.1(react@19.2.4))(@react-email/code-block@0.2.1(react@19.2.4))(@react-email/code-inline@0.0.6(react@19.2.4))(@react-email/container@0.0.16(react@19.2.4))(@react-email/heading@0.0.16(react@19.2.4))(@react-email/hr@0.0.12(react@19.2.4))(@react-email/img@0.0.12(react@19.2.4))(@react-email/link@0.0.13(react@19.2.4))(@react-email/preview@0.0.14(react@19.2.4))(@react-email/text@0.1.6(react@19.2.4))(react@19.2.4)':
dependencies:
'@react-email/text': 0.1.6(react@19.2.4)
react: 19.2.4
tailwindcss: 4.1.18
optionalDependencies:
'@react-email/body': 0.2.1(react@19.2.4)
'@react-email/button': 0.2.1(react@19.2.4)
'@react-email/code-block': 0.2.1(react@19.2.4)
'@react-email/code-inline': 0.0.6(react@19.2.4)
'@react-email/container': 0.0.16(react@19.2.4)
'@react-email/heading': 0.0.16(react@19.2.4)
'@react-email/hr': 0.0.12(react@19.2.4)
'@react-email/img': 0.0.12(react@19.2.4)
'@react-email/link': 0.0.13(react@19.2.4)
'@react-email/preview': 0.0.14(react@19.2.4)
'@react-email/text@0.1.6(react@19.2.4)':
dependencies:
react: 19.2.4
'@react-native/assets-registry@0.81.4': {}
'@react-native/assets-registry@0.83.1': {}
@@ -16785,6 +17244,11 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@selderee/plugin-htmlparser2@0.11.0':
dependencies:
domhandler: 5.0.3
selderee: 0.11.0
'@shikijs/core@3.21.0':
dependencies:
'@shikijs/types': 3.21.0
@@ -16881,7 +17345,7 @@ snapshots:
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
dependencies:
'@types/jest': 28.1.8
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
eslint: 8.57.1
eslint-plugin-react: 7.37.5(eslint@8.57.1)
@@ -17236,7 +17700,7 @@ snapshots:
'@types/node': 22.19.6
optional: true
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
dependencies:
'@eslint-community/regexpp': 4.12.2
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
@@ -17799,6 +18263,14 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.3.6
ai@6.0.94(zod@4.3.5):
dependencies:
'@ai-sdk/gateway': 3.0.52(zod@4.3.5)
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
'@opentelemetry/api': 1.9.0
zod: 4.3.5
ai@6.0.94(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 3.0.52(zod@4.3.6)
@@ -18804,10 +19276,28 @@ snapshots:
'@babel/runtime': 7.28.6
csstype: 3.2.3
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
domelementtype@2.3.0: {}
domexception@2.0.1:
dependencies:
webidl-conversions: 5.0.0
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dotenv-expand@11.0.7:
dependencies:
dotenv: 16.4.7
@@ -18912,6 +19402,8 @@ snapshots:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@4.5.0: {}
entities@6.0.1: {}
entities@7.0.1: {}
@@ -20376,10 +20868,25 @@ snapshots:
html-escaper@2.0.2: {}
html-to-text@9.0.5:
dependencies:
'@selderee/plugin-htmlparser2': 0.11.0
deepmerge: 4.3.1
dom-serializer: 2.0.0
htmlparser2: 8.0.2
selderee: 0.11.0
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -21400,6 +21907,8 @@ snapshots:
lan-network@0.1.7: {}
leac@0.6.0: {}
leven@3.1.0: {}
levn@0.4.1:
@@ -21603,6 +22112,8 @@ snapshots:
markdown-table@3.0.4: {}
marked@15.0.12: {}
marked@17.0.1: {}
marky@1.3.0: {}
@@ -22762,6 +23273,11 @@ snapshots:
dependencies:
entities: 6.0.1
parseley@0.12.1:
dependencies:
leac: 0.6.0
peberminta: 0.9.0
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@@ -22794,6 +23310,8 @@ snapshots:
pathe@2.0.3: {}
peberminta@0.9.0: {}
pend@1.2.0: {}
picocolors@1.1.1: {}
@@ -22947,6 +23465,8 @@ snapshots:
dependencies:
parse-ms: 4.0.0
prismjs@1.30.0: {}
proc-log@4.2.0: {}
progress@2.0.3: {}
@@ -23980,6 +24500,10 @@ snapshots:
extend-shallow: 2.0.1
kind-of: 6.0.3
selderee@0.11.0:
dependencies:
parseley: 0.12.1
semver@6.3.1: {}
semver@7.5.3:
@@ -25746,6 +26270,8 @@ snapshots:
zod@3.25.76: {}
zod@4.3.5: {}
zod@4.3.6: {}
zustand@4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
+175
View File
@@ -0,0 +1,175 @@
---
name: json-render-react-email
description: React Email renderer for json-render that turns JSON specs into HTML or plain-text emails using @react-email/components and @react-email/render. Use when working with @json-render/react-email, building transactional or marketing emails from JSON, creating email catalogs, rendering AI-generated email specs, or when the user mentions react-email, HTML email, or transactional email.
metadata:
tags: react-email, email, json-render, html email, transactional email
---
# @json-render/react-email
React Email renderer that converts JSON specs into HTML or plain-text email output.
## Quick Start
```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);
```
## Spec Structure (Element Tree)
Same flat element tree as `@json-render/react`: `root` key plus `elements` map. Root must be `Html`; children of `Html` should be `Head` and `Body`. Use `Container` (e.g. max-width 600px) inside `Body` for client-safe layout.
## Creating a Catalog and Registry
```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 });
```
## Server-Side Render APIs
| Function | Purpose |
|----------|---------|
| `renderToHtml(spec, options?)` | Render spec to HTML email string |
| `renderToPlainText(spec, options?)` | Render spec to plain-text email string |
`RenderOptions`: `registry`, `includeStandard` (default true), `state` (for `$state` / `$cond`).
## Visibility and State
Supports `visible` conditions, `$state`, `$cond`, repeat (`repeat.statePath`), and the same expression syntax as `@json-render/react`. Use `state` in `RenderOptions` when rendering server-side so expressions resolve.
## Server-Safe Import
Import schema and catalog without React or `@react-email/components`:
```typescript
import { schema, standardComponentDefinitions } from "@json-render/react-email/server";
```
## Key Exports
| Export | Purpose |
|--------|---------|
| `defineRegistry` | Create type-safe component registry from catalog |
| `Renderer` | Render spec in browser (e.g. preview); use with `JSONUIProvider` for state/actions |
| `createRenderer` | Standalone renderer component with state/actions/validation |
| `renderToHtml` | Server: spec to HTML string |
| `renderToPlainText` | Server: spec to plain-text string |
| `schema` | Email element schema |
| `standardComponents` | Pre-built component implementations |
| `standardComponentDefinitions` | Catalog definitions (Zod props) |
## Sub-path Exports
| Path | Purpose |
|------|---------|
| `@json-render/react-email` | Full package |
| `@json-render/react-email/server` | Schema and catalog only (no React) |
| `@json-render/react-email/catalog` | Standard component definitions and types |
| `@json-render/react-email/render` | Render functions only |
## Standard Components
All components accept a `style` prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS.
### Document structure
| Component | Description |
|-----------|-------------|
| `Html` | Root wrapper (lang, dir). Children: Head, Body. |
| `Head` | Email head section. |
| `Body` | Body wrapper; use `style` for background. |
### Layout
| Component | Description |
|-----------|-------------|
| `Container` | Constrain width (e.g. max-width 600px). |
| `Section` | Group content; table-based for compatibility. |
| `Row` | Horizontal row. |
| `Column` | Column in a Row; set width via style. |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | Heading text (as: h1–h6). |
| `Text` | Body text. |
| `Link` | Hyperlink (text, href). |
| `Button` | CTA link styled as button (text, href). |
| `Image` | Image from URL (src, alt, width, height). |
| `Hr` | Horizontal rule. |
### Utility
| Component | Description |
|-----------|-------------|
| `Preview` | Inbox preview text (inside Html). |
| `Markdown` | Markdown content as email-safe HTML. |
## Email Best Practices
- Keep width constrained (e.g. Container max-width 600px).
- Use inline styles or React Email's style props; many clients strip `<style>` blocks.
- Prefer table-based layout (Section, Row, Column) for broad client support.
- Use absolute URLs for images; many clients block relative or cid: references in some contexts.
- Test in multiple clients (Gmail, Outlook, Apple Mail); use a preview tool or Litmus-like service when possible.