Feat/react email support (#159)
* add react-email workspace dependencies * add react-email package * add react-email example with Vercel Invite, Stripe Welcome and Nike Receipt * add render tests for react-email package * fix missing style prop on stripe-welcome logo Image * add react email documentation * add json render react email skill * fix @internal/react-state alias in vitest config Pre-existing issue: vitest could not resolve @internal/react-state, causing 6 test suites in packages/react and packages/react-pdf to fail. * fix PR review feedback - fix tsconfig: remove rootDir, expand include for test imports - fix render tests: add non-null assertions for noUncheckedIndexedAccess - fix dev script: add portless to match other examples - fix .env.example: correct comment from video to email generation - fix docs: add blank line before heading in installation page * merge latest * fix: update tsconfig extends from @repo to @internal/typescript-config * fixes * fixes --------- Co-authored-by: WManzoli <willmanzoli@gmail.com> Co-authored-by: Chris Tate <chris@ctate.dev>
@@ -6,6 +6,7 @@
|
||||
[
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/react-email",
|
||||
"@json-render/react-pdf",
|
||||
"@json-render/shadcn",
|
||||
"@json-render/react-native",
|
||||
|
||||
@@ -15,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<K></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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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: {},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: [
|
||||
"@react-email/components",
|
||||
"@react-email/render",
|
||||
"@json-render/react-email",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 233 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 426 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 468 KiB |
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
});
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||