Compare commits

...
Author SHA1 Message Date
Chris Tate d05613f957 update header font 2026-02-22 15:42:42 -06:00
Chris Tate 62932f6516 fix gitignore (#134) 2026-02-22 15:20:33 -06:00
Chris Tate ee28d548c1 use portless (#133) 2026-02-22 15:15:17 -06:00
Chris Tate 0e6f2afc6c fix og (#128) 2026-02-20 02:41:16 -06:00
Chris Tate bccedc2459 add docs (#127) 2026-02-20 02:30:33 -06:00
github-actions[bot] 0a404302ef chore: version packages (#126)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-20 02:17:13 -06:00
Chris Tate 09376db2f6 v0.8.0 changeset (#125) 2026-02-20 02:15:51 -06:00
Chris Tate 4c294417f4 pdf (#124)
* no-ai

* pdf

* pdf example

* fixes

* ai gateway

* fixes

* fixes

* fixes

* shadcn

* 3 panes

* fixes

* fixes

* fixes

* fixes

* fix CI

* fix
2026-02-20 02:08:25 -06:00
116 changed files with 8945 additions and 111 deletions
+1
View File
@@ -6,6 +6,7 @@
[
"@json-render/core",
"@json-render/react",
"@json-render/react-pdf",
"@json-render/shadcn",
"@json-render/react-native",
"@json-render/remotion",
+2 -6
View File
@@ -6,11 +6,8 @@ node_modules
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env*
!.env.example
# Testing
coverage
@@ -43,7 +40,6 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
.env*.local
# Stripe apps (generated from template + build artifacts)
examples/stripe-app/*/stripe-app.json
+20
View File
@@ -27,6 +27,26 @@ This ensures we don't install outdated versions that may have incompatible types
- Do not use emojis in code or UI
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
## Dev Servers
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
Naming convention:
- Main web app: `json-render` → `json-render.localhost:1355`
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
```json
{
"scripts": {
"dev": "portless my-example-demo.json-render next dev --turbopack"
}
}
```
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
## Workflow
- Run `pnpm type-check` after each turn to ensure type safety
+40 -3
View File
@@ -12,6 +12,8 @@ npm install @json-render/shadcn
npm install @json-render/core @json-render/react-native
# or for video
npm install @json-render/core @json-render/remotion
# or for PDF documents
npm install @json-render/core @json-render/react-pdf
```
## Why json-render?
@@ -111,6 +113,7 @@ function Dashboard({ spec }) {
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
| `@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 |
## Renderers
@@ -221,6 +224,40 @@ const spec = {
/>
```
### React PDF (Documents)
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
const spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page-1"] },
"page-1": {
type: "Page",
props: { size: "A4" },
children: ["heading-1", "table-1"],
},
"heading-1": {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
"table-1": {
type: "Table",
props: {
columns: [{ header: "Item", width: "60%" }, { header: "Price", width: "40%", align: "right" }],
rows: [["Widget A", "$10.00"], ["Widget B", "$25.00"]],
},
children: [],
},
},
};
// Render to buffer, stream, or file
const buffer = await renderToBuffer(spec);
```
## Features
### Streaming (SpecStream)
@@ -306,9 +343,9 @@ pnpm install
pnpm dev
```
- http://localhost:3000 - Docs & Playground
- http://localhost:3001 - Example Dashboard
- http://localhost:3002 - Remotion Video Example
- http://json-render.localhost:1355 - Docs & Playground
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
- Chat Example: run `pnpm dev` in `examples/chat`
- React Native example: run `npx expo start` in `examples/react-native`
+1 -1
View File
@@ -14,7 +14,7 @@ pnpm dev
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "A2UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/a2ui")
# A2UI Integration
@@ -1,4 +1,5 @@
export const metadata = { title: "Adaptive Cards Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/adaptive-cards")
# Adaptive Cards Integration
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "AG-UI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ag-ui")
# AG-UI Integration
+3 -2
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "AI SDK Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/ai-sdk")
# AI SDK Integration
@@ -214,7 +215,7 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>catalog.prompt({ mode: "chat" })</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/codegen API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/codegen")
# @json-render/codegen
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/core API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/core")
# @json-render/core
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react-native API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-native")
# @json-render/react-native
@@ -0,0 +1,423 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react-pdf")
# @json-render/react-pdf
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
## Install
```bash
npm install @json-render/core @json-render/react-pdf
```
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
## schema
The PDF element schema for document specs. Use with `defineCatalog` from core.
```typescript
import { defineCatalog } from '@json-render/core';
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
```
## Render Functions
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
```typescript
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
const buffer = await renderToBuffer(spec);
const stream = await renderToStream(spec);
stream.pipe(res);
await renderToFile(spec, './output.pdf');
```
### RenderOptions
```typescript
interface RenderOptions {
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
state?: Record<string, unknown>;
}
```
<table>
<thead>
<tr>
<th>Option</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>registry</code></td>
<td>Custom component map (merged with standard components)</td>
</tr>
<tr>
<td><code>includeStandard</code></td>
<td>Include built-in standard components (default: <code>true</code>)</td>
</tr>
<tr>
<td><code>state</code></td>
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
</tr>
</tbody>
</table>
## defineRegistry
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
```tsx
import { defineRegistry } from '@json-render/react-pdf';
import { View, Text } from '@react-pdf/renderer';
const { registry } = defineRegistry(catalog, {
components: {
Badge: ({ props }) => (
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
<Text style={{ fontSize: 10 }}>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });
```
## createRenderer
Create a standalone renderer component wired to state, actions, and validation.
```typescript
import { createRenderer } from '@json-render/react-pdf';
const PDFRenderer = createRenderer(catalog, components);
```
```typescript
interface CreateRendererProps {
spec: Spec | null;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (path: string, value: unknown) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
```
## Renderer
The main component that renders a spec to `@react-pdf/renderer` elements.
```typescript
interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean; // default: true
loading?: boolean;
fallback?: ComponentRenderer;
}
```
## Standard Components
### Document Structure
#### Document
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
```typescript
{
title: string | null;
author: string | null;
subject: string | null;
}
```
#### Page
A page in the document with configurable size, orientation, and margins.
```typescript
{
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
orientation: "portrait" | "landscape" | null;
marginTop: number | null;
marginBottom: number | null;
marginLeft: number | null;
marginRight: number | null;
backgroundColor: string | null;
}
```
### Layout
#### View
Generic container with padding, margin, background, border, and flex alignment.
```typescript
{
padding: number | null;
paddingTop: number | null;
paddingBottom: number | null;
paddingLeft: number | null;
paddingRight: number | null;
margin: number | null;
backgroundColor: string | null;
borderWidth: number | null;
borderColor: string | null;
borderRadius: number | null;
flex: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
}
```
#### Row
Horizontal flex layout with optional wrapping.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
wrap: boolean | null;
}
```
#### Column
Vertical flex layout.
```typescript
{
gap: number | null;
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
padding: number | null;
flex: number | null;
}
```
### Content
#### Heading
h1-h4 heading text with configurable color and alignment.
```typescript
{
text: string;
level: "h1" | "h2" | "h3" | "h4" | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
#### Text
Body text with full styling control.
```typescript
{
text: string;
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
fontWeight: "normal" | "bold" | null;
fontStyle: "normal" | "italic" | null;
lineHeight: number | null;
}
```
#### Image
Image from a URL with optional dimensions and fit.
```typescript
{
src: string;
width: number | null;
height: number | null;
objectFit: "contain" | "cover" | "fill" | "none" | null;
}
```
#### Link
Hyperlink with visible text.
```typescript
{
text: string;
href: string;
fontSize: number | null;
color: string | null;
}
```
### Data
#### Table
Data table with typed columns and string rows. Supports header styling and striped rows.
```typescript
{
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
rows: string[][];
headerBackgroundColor: string | null;
headerTextColor: string | null;
borderColor: string | null;
fontSize: number | null;
striped: boolean | null;
}
```
#### List
Ordered or unordered list.
```typescript
{
items: string[];
ordered: boolean | null;
fontSize: number | null;
color: string | null;
spacing: number | null;
}
```
### Decorative
#### Divider
Horizontal line separator.
```typescript
{
color: string | null;
thickness: number | null;
marginTop: number | null;
marginBottom: number | null;
}
```
#### Spacer
Empty vertical space.
```typescript
{
height: number | null;
}
```
### Page-Level
#### PageNumber
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
```typescript
{
format: string | null; // default: "{pageNumber} / {totalPages}"
fontSize: number | null;
color: string | null;
align: "left" | "center" | "right" | null;
}
```
## Server-Safe Import
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
```typescript
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
```
## Sub-path Exports
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>@json-render/react-pdf</code></td>
<td>Full package: schema, renderer, components, render functions</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/server</code></td>
<td>Schema and catalog definitions only (no React)</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/catalog</code></td>
<td>Standard component definitions and types</td>
</tr>
<tr>
<td><code>@json-render/react-pdf/render</code></td>
<td>Server-side render functions only</td>
</tr>
</tbody>
</table>
## Types
<table>
<thead>
<tr>
<th>Export</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>ReactPdfSchema</code></td>
<td>Schema type for PDF specs</td>
</tr>
<tr>
<td><code>ReactPdfSpec</code></td>
<td>Spec type for PDF documents</td>
</tr>
<tr>
<td><code>RenderOptions</code></td>
<td>Options for render functions</td>
</tr>
<tr>
<td><code>ComponentContext</code></td>
<td>Typed component render function context</td>
</tr>
<tr>
<td><code>ComponentFn</code></td>
<td>Component render function type</td>
</tr>
<tr>
<td><code>StandardComponentDefinitions</code></td>
<td>Type of the standard component definitions object</td>
</tr>
<tr>
<td><code>StandardComponentProps&lt;K&gt;</code></td>
<td>Inferred props type for a standard component by name</td>
</tr>
</tbody>
</table>
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/react API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/react")
# @json-render/react
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/remotion API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/remotion")
# @json-render/remotion
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "@json-render/shadcn API" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/shadcn")
# @json-render/shadcn
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Catalog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/catalog")
# Catalog
+64 -1
View File
@@ -1,9 +1,72 @@
export const metadata = { title: "Changelog" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/changelog")
# Changelog
Notable changes and updates to json-render.
## v0.8.0
February 2026
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
```bash
npm install @json-render/core @json-render/react-pdf
```
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
```
Server-side rendering APIs:
- `renderToBuffer(spec)` -- render to an in-memory PDF buffer
- `renderToStream(spec)` -- render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` -- render directly to a file
15 standard components covering document structure (Document, Page), layout (View, Row, Column), content (Heading, Text, Image, Link), data (Table, List), decorative (Divider, Spacer), and page-level (PageNumber).
Supports custom catalogs with `defineRegistry`, server-safe imports via `@json-render/react-pdf/server`, and full context support (state, visibility, actions, validation, repeat scopes).
---
## v0.7.0
February 2026
@@ -1,4 +1,5 @@
export const metadata = { title: "Code Export" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/code-export")
# Code Export
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
```bash
cd examples/dashboard
pnpm dev
# Open http://localhost:3001
# Open http://dashboard-demo.json-render.localhost:1355
# Generate a widget, then click "Export Project"
```
@@ -1,4 +1,5 @@
export const metadata = { title: "Custom Schema & Renderer" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/custom-schema")
# Custom Schema & Renderer
@@ -1,4 +1,5 @@
export const metadata = { title: "Data Binding" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/data-binding")
# Data Binding
@@ -1,4 +1,5 @@
export const metadata = { title: "Generation Modes" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/generation-modes")
# Generation Modes
@@ -1,4 +1,5 @@
export const metadata = { title: "Installation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/installation")
# Installation
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Migration Guide" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/migration")
# Migration Guide
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "OpenAPI Integration" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/openapi")
# OpenAPI Integration
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Introduction" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs")
# Introduction
@@ -1,4 +1,5 @@
export const metadata = { title: "Quick Start" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/quick-start")
# Quick Start
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Registry" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/registry")
# Registry
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Schemas" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/schemas")
# Schemas
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Specs" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/specs")
# Specs
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Streaming" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/streaming")
# Streaming
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Validation" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/validation")
# Validation
+2 -1
View File
@@ -1,4 +1,5 @@
export const metadata = { title: "Visibility" }
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/visibility")
# Visibility
+15 -4
View File
@@ -154,23 +154,34 @@ button {
margin-bottom: 0.5em;
}
/* MDX table styles — fallback for GFM-generated tables */
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
.mdx-table th,
.mdx-table td {
.mdx-table td,
article table th,
article table td {
border: 1px solid var(--border);
padding: 0.75rem 1rem;
text-align: left;
}
.mdx-table th {
.mdx-table th,
article table th {
font-weight: 600;
background-color: var(--muted);
}
.mdx-table td {
.mdx-table td,
article table td {
color: var(--muted-foreground);
}
article table {
width: 100%;
font-size: 0.875rem;
border-collapse: collapse;
margin: 1.5rem 0;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
+4 -2
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { GeistPixelSquare } from "geist/font/pixel";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
description:
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
images: ["/og"],
creator: "@verabornnot",
},
robots: {
index: true,
@@ -92,7 +92,9 @@ export default async function RootLayout({
/>
)}
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
>
<ThemeProvider>
{children}
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
+1 -1
View File
@@ -57,7 +57,7 @@ export function Header() {
</svg>
</span>
<Link href="/">
<span className="font-medium tracking-tight text-lg">
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
json-render
</span>
</Link>
+6
View File
@@ -57,6 +57,11 @@ export const docsNavigation: NavSection[] = [
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
external: true,
},
{
title: "React PDF",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
external: true,
},
{
title: "Remotion",
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
@@ -86,6 +91,7 @@ export const docsNavigation: NavSection[] = [
items: [
{ 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/shadcn", href: "/docs/api/shadcn" },
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import { PAGE_TITLES } from "./page-titles";
const DESCRIPTION =
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
export function pageMetadata(slug: string): Metadata {
const title = PAGE_TITLES[slug];
if (!title) return {};
const displayTitle = title.replace(/\n/g, " ");
const fullTitle = `${displayTitle} | json-render`;
const ogImageUrl = slug ? `/og/${slug}` : "/og";
return {
title: displayTitle,
openGraph: {
type: "website",
locale: "en_US",
siteName: "json-render",
title: fullTitle,
description: DESCRIPTION,
images: [
{
url: ogImageUrl,
width: 1200,
height: 630,
alt: `${displayTitle} - json-render`,
},
],
},
twitter: {
card: "summary_large_image",
title: fullTitle,
description: DESCRIPTION,
images: [ogImageUrl],
},
};
}
+2
View File
@@ -38,9 +38,11 @@ export const PAGE_TITLES: Record<string, string> = {
// API references
"docs/api/core": "@json-render/core API",
"docs/api/react": "@json-render/react API",
"docs/api/react-pdf": "@json-render/react-pdf API",
"docs/api/react-native": "@json-render/react-native API",
"docs/api/codegen": "@json-render/codegen API",
"docs/api/remotion": "@json-render/remotion API",
"docs/api/shadcn": "@json-render/shadcn API",
};
/**
+2 -1
View File
@@ -5,7 +5,7 @@
"private": true,
"license": "Apache-2.0",
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
@@ -33,6 +33,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
"just-bash": "2.9.6",
"lucide-react": "^0.562.0",
"next": "16.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
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.
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless chat-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
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.
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "portless dashboard-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
+105
View File
@@ -0,0 +1,105 @@
@import "tailwindcss";
@import "tw-animate-css";
@source "../../../packages/shadcn/src/**/*.tsx";
@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);
}
: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);
}
.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);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
button {
cursor: pointer;
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "json-render No-AI Example",
description: "Static JSON specs rendered with json-render -- no AI required",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} font-sans antialiased`}
>
{children}
</body>
</html>
);
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useState, useEffect, useCallback, type ReactNode } from "react";
import ConfettiExplosion from "react-confetti-explosion";
import {
Renderer,
StateProvider,
VisibilityProvider,
ActionProvider,
ValidationProvider,
} from "@json-render/react";
import type { Spec } from "@json-render/core";
import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
import { examples } from "@/lib/examples";
function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
return (
<StateProvider initialState={spec.state ?? {}}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer spec={spec} registry={registry} />
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
export default function Page() {
const [selectedIndex] = useState(0);
const selected = examples[selectedIndex]!;
const [confettiKey, setConfettiKey] = useState(0);
const [confettiActive, setConfettiActive] = useState(false);
const fireConfetti = useCallback(() => {
setConfettiKey((k) => k + 1);
setConfettiActive(true);
}, []);
useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
return (
<div className="h-screen flex items-center justify-center bg-muted/30">
<div
className="relative bg-background border rounded-lg shadow-sm"
style={{ width: 960, height: 1080 }}
>
{confettiActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<ConfettiExplosion
key={confettiKey}
portal={false}
force={0.8}
duration={3500}
particleCount={400}
particleSize={8}
colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
width={1600}
height="200vh"
zIndex={1}
onComplete={() => setConfettiActive(false)}
/>
</div>
)}
<div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
<SpecRenderer key={selectedIndex} spec={selected.spec} />
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { nextJsConfig } from "@repo/eslint-config/next-js";
/** @type {import("eslint").Linter.Config[]} */
export default [
...nextJsConfig,
{
rules: {
"react/prop-types": "off",
"react/no-unknown-property": ["error", { ignore: ["jsx"] }],
},
},
];
+511
View File
@@ -0,0 +1,511 @@
import type { Spec } from "@json-render/core";
export interface Example {
name: string;
description: string;
spec: Spec;
}
export const examples: Example[] = [
{
name: "Confetti",
description: "A button that fires confetti when clicked",
spec: {
root: "btn",
elements: {
btn: {
type: "Button",
props: { label: "Click me", variant: "primary" },
on: { press: { action: "confetti" } },
},
},
},
},
{
name: "User Profile Card",
description: "A card with user info, badges, and a progress bar",
spec: {
root: "card",
elements: {
card: {
type: "Card",
props: {
title: "User Profile",
description: null,
maxWidth: "md",
centered: null,
},
children: ["stack"],
},
stack: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: ["heading", "text", "badges", "sep", "progress"],
},
heading: {
type: "Heading",
props: { text: "Jane Cooper", level: "h2" },
},
text: {
type: "Text",
props: {
text: "Senior software engineer based in San Francisco. Passionate about building accessible, high-performance web applications.",
variant: "muted",
},
},
badges: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: null,
},
children: ["b1", "b2", "b3"],
},
b1: {
type: "Badge",
props: { text: "TypeScript", variant: "default" },
},
b2: { type: "Badge", props: { text: "React", variant: "secondary" } },
b3: { type: "Badge", props: { text: "Node.js", variant: "outline" } },
sep: { type: "Separator", props: { orientation: null } },
progress: {
type: "Progress",
props: { value: 72, max: 100, label: "Profile completion" },
},
},
},
},
{
name: "Settings Form",
description: "A form with inputs, selects, switches, and checkboxes",
spec: {
root: "card",
state: {
name: "Ada Lovelace",
email: "ada@example.com",
role: "Engineer",
notifications: true,
darkMode: false,
},
elements: {
card: {
type: "Card",
props: {
title: "Account Settings",
description: "Manage your preferences",
maxWidth: "md",
centered: null,
},
children: ["form"],
},
form: {
type: "Stack",
props: {
direction: "vertical",
gap: "md",
align: null,
justify: null,
},
children: [
"nameInput",
"emailInput",
"roleSelect",
"sep",
"notifSwitch",
"darkSwitch",
"sep2",
"actions",
],
},
nameInput: {
type: "Input",
props: {
label: "Full Name",
name: "name",
type: "text",
placeholder: "Your name",
value: { $bindState: "/name" },
checks: null,
},
},
emailInput: {
type: "Input",
props: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
value: { $bindState: "/email" },
checks: null,
},
},
roleSelect: {
type: "Select",
props: {
label: "Role",
name: "role",
options: [
"Engineer",
"Designer",
"Product Manager",
"Data Scientist",
],
placeholder: "Choose a role",
value: { $bindState: "/role" },
checks: null,
},
},
sep: { type: "Separator", props: { orientation: null } },
notifSwitch: {
type: "Switch",
props: {
label: "Email notifications",
name: "notifications",
checked: { $bindState: "/notifications" },
},
},
darkSwitch: {
type: "Switch",
props: {
label: "Dark mode",
name: "darkMode",
checked: { $bindState: "/darkMode" },
},
},
sep2: { type: "Separator", props: { orientation: null } },
actions: {
type: "Stack",
props: {
direction: "horizontal",
gap: "sm",
align: null,
justify: "end",
},
children: ["cancelBtn", "saveBtn"],
},
cancelBtn: {
type: "Button",
props: { label: "Cancel", variant: "secondary", disabled: null },
},
saveBtn: {
type: "Button",
props: { label: "Save Changes", variant: "primary", disabled: null },
},
},
},
},
{
name: "Pricing Table",
description: "A grid of pricing cards with feature lists",
spec: {
root: "outer",
elements: {
outer: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["header", "grid"],
},
header: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: "center",
justify: null,
},
children: ["title", "subtitle"],
},
title: {
type: "Heading",
props: { text: "Simple, transparent pricing", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Choose the plan that fits your needs. Upgrade or downgrade at any time.",
variant: "muted",
},
},
grid: {
type: "Grid",
props: { columns: 3, gap: "md" },
children: ["free", "pro", "enterprise"],
},
free: {
type: "Card",
props: {
title: "Free",
description: "$0/month",
maxWidth: null,
centered: null,
},
children: ["freeContent"],
},
freeContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["f1", "f2", "f3", "freeBtn"],
},
f1: {
type: "Text",
props: { text: "Up to 3 projects", variant: "body" },
},
f2: { type: "Text", props: { text: "1 GB storage", variant: "body" } },
f3: {
type: "Text",
props: { text: "Community support", variant: "body" },
},
freeBtn: {
type: "Button",
props: { label: "Get Started", variant: "secondary", disabled: null },
},
pro: {
type: "Card",
props: {
title: "Pro",
description: "$19/month",
maxWidth: null,
centered: null,
},
children: ["proContent"],
},
proContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["p1", "p2", "p3", "p4", "proBtn"],
},
p1: {
type: "Text",
props: { text: "Unlimited projects", variant: "body" },
},
p2: { type: "Text", props: { text: "50 GB storage", variant: "body" } },
p3: {
type: "Text",
props: { text: "Priority support", variant: "body" },
},
p4: {
type: "Text",
props: { text: "Custom domains", variant: "body" },
},
proBtn: {
type: "Button",
props: {
label: "Upgrade to Pro",
variant: "primary",
disabled: null,
},
},
enterprise: {
type: "Card",
props: {
title: "Enterprise",
description: "Custom pricing",
maxWidth: null,
centered: null,
},
children: ["entContent"],
},
entContent: {
type: "Stack",
props: {
direction: "vertical",
gap: "sm",
align: null,
justify: null,
},
children: ["e1", "e2", "e3", "e4", "entBtn"],
},
e1: {
type: "Text",
props: { text: "Everything in Pro", variant: "body" },
},
e2: {
type: "Text",
props: { text: "Unlimited storage", variant: "body" },
},
e3: {
type: "Text",
props: { text: "Dedicated support", variant: "body" },
},
e4: {
type: "Text",
props: { text: "SLA guarantees", variant: "body" },
},
entBtn: {
type: "Button",
props: {
label: "Contact Sales",
variant: "secondary",
disabled: null,
},
},
},
},
},
{
name: "Status Dashboard",
description: "System status with alerts, a table, and an accordion",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: [
"heading",
"alertOk",
"alertWarn",
"sep",
"table",
"sep2",
"accordion",
],
},
heading: {
type: "Heading",
props: { text: "System Status", level: "h1" },
},
alertOk: {
type: "Alert",
props: {
title: "API",
message: "All systems operational -- 99.98% uptime this month",
type: "success",
},
},
alertWarn: {
type: "Alert",
props: {
title: "Database",
message: "Elevated latency detected in us-east-1 region",
type: "warning",
},
},
sep: { type: "Separator", props: { orientation: null } },
table: {
type: "Table",
props: {
columns: ["Service", "Status", "Latency", "Uptime"],
rows: [
["API Gateway", "Operational", "12ms", "99.99%"],
["Auth Service", "Operational", "8ms", "99.98%"],
["Database", "Degraded", "145ms", "99.85%"],
["CDN", "Operational", "3ms", "100%"],
["Workers", "Operational", "22ms", "99.97%"],
],
caption: "Last updated 2 minutes ago",
},
},
sep2: { type: "Separator", props: { orientation: null } },
accordion: {
type: "Accordion",
props: {
items: [
{
title: "What does 'Degraded' mean?",
content:
"The service is still operational but experiencing slower response times than normal. We are actively investigating.",
},
{
title: "How is uptime calculated?",
content:
"Uptime is measured over the current calendar month based on successful health-check pings every 30 seconds.",
},
{
title: "How do I subscribe to updates?",
content:
"You can subscribe to incident notifications via email or webhook on the Settings page.",
},
],
type: "single",
},
},
},
},
},
{
name: "Feature Comparison",
description: "Tables inside an accordion for comparing plan tiers",
spec: {
root: "root",
elements: {
root: {
type: "Stack",
props: {
direction: "vertical",
gap: "lg",
align: null,
justify: null,
},
children: ["heading", "subtitle", "accordion"],
},
heading: {
type: "Heading",
props: { text: "Feature Comparison", level: "h1" },
},
subtitle: {
type: "Text",
props: {
text: "Expand each section to see how plans compare.",
variant: "muted",
},
},
accordion: {
type: "Accordion",
props: {
items: [
{
title: "Projects & Team",
content:
"Free: 3 projects, 1 member | Pro: Unlimited projects, 10 members | Enterprise: Unlimited everything",
},
{
title: "Storage & Bandwidth",
content:
"Free: 1 GB storage, 10 GB/mo bandwidth | Pro: 50 GB storage, 500 GB/mo | Enterprise: Unlimited",
},
{
title: "Support & SLA",
content:
"Free: Forum, no SLA | Pro: Email + Chat, 99.9% SLA | Enterprise: Dedicated Slack, 99.99% SLA",
},
],
type: "single",
},
},
},
},
},
];
+16
View File
@@ -0,0 +1,16 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
import { z } from "zod";
export const catalog = defineCatalog(schema, {
components: {
...shadcnComponentDefinitions,
},
actions: {
confetti: {
params: z.object({}),
description: "Fire confetti",
},
},
});
+29
View File
@@ -0,0 +1,29 @@
"use client";
import { defineRegistry } from "@json-render/react";
import { shadcnComponents } from "@json-render/shadcn";
import { catalog } from "./catalog";
let confettiListener: (() => void) | null = null;
export function onConfetti(cb: () => void) {
confettiListener = cb;
return () => {
confettiListener = null;
};
}
export const { registry } = defineRegistry(catalog, {
components: {
...shadcnComponents,
},
actions: {
confetti: async () => {
confettiListener?.();
},
},
});
export const actionHandlers: Record<string, () => void> = {
confetti: () => confettiListener?.(),
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+5
View File
@@ -0,0 +1,5 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
+40
View File
@@ -0,0 +1,40 @@
{
"name": "example-no-ai",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "portless no-ai-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "eslint --max-warnings 0",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/shadcn": "workspace:*",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-confetti-explosion": "^3.0.3",
"tailwind-merge": "^3.4.0",
"zod": "4.3.5"
},
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"eslint": "^9.39.1",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+5
View File
@@ -0,0 +1,5 @@
# Required: AI Gateway API key
AI_GATEWAY_API_KEY=
# Optional: override the default model (default: anthropic/claude-haiku-4.5)
# AI_GATEWAY_MODEL=openai/gpt-4o-mini
@@ -0,0 +1,35 @@
import { streamText } from "ai";
import { gateway } from "@ai-sdk/gateway";
import { buildUserPrompt, type Spec } from "@json-render/core";
import { pdfCatalog } from "@/lib/catalog";
export const maxDuration = 60;
const SYSTEM_PROMPT = pdfCatalog.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: gateway(process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL),
system: SYSTEM_PROMPT,
prompt: userPrompt,
temperature: 0.7,
});
return result.toTextStreamResponse();
}
+46
View File
@@ -0,0 +1,46 @@
import { renderToBuffer } from "@json-render/react-pdf/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") ?? "invoice";
const download = searchParams.get("download") === "1";
const example = examples.find((e) => e.name === name);
if (!example) {
return new Response("Example not found", { status: 404 });
}
return pdfResponse(example.spec, name, download);
}
export async function POST(req: Request) {
const { spec, download, filename } = (await req.json()) as {
spec: Spec;
download?: boolean;
filename?: string;
};
if (!spec || !spec.root || !spec.elements) {
return new Response("Invalid spec", { status: 400 });
}
return pdfResponse(spec, filename ?? "document", download ?? false);
}
async function pdfResponse(spec: Spec, name: string, download: boolean) {
const buffer = await renderToBuffer(spec);
const disposition = download
? `attachment; filename="${name}.pdf"`
: `inline; filename="${name}.pdf"`;
return new Response(buffer as unknown as ArrayBuffer, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": disposition,
"Cache-Control": "no-store",
},
});
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "json-render React PDF Example",
description:
"Generate PDF documents from JSON specs with @json-render/react-pdf",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
+615
View File
@@ -0,0 +1,615 @@
"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 PDF_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 !== "Document" || !root.children?.length) return false;
const firstChild = spec.elements[root.children[0]!];
return firstChild?.type === "Page";
}
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 [pdfUrl, setPdfUrl] = 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 pdfUrlRef = useRef<string | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
const abortRef = useRef<AbortController | null>(null);
const codeScrollRef = useRef<HTMLDivElement>(null);
const mobileCodeScrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!generating) return;
codeScrollRef.current?.scrollTo({
top: codeScrollRef.current.scrollHeight,
});
mobileCodeScrollRef.current?.scrollTo({
top: mobileCodeScrollRef.current.scrollHeight,
});
}, [generating, generatedSpec]);
const currentExample =
selection.mode === "example"
? examples.find((e) => e.name === selection.exampleName)
: null;
const activeSpec = generatedSpec ?? currentExample?.spec ?? null;
const examplePdfUrl =
selection.mode === "example" && !generatedSpec
? `/api/pdf?name=${selection.exampleName}`
: null;
const displayPdfUrl = pdfUrl ?? examplePdfUrl;
useEffect(() => {
inputRef.current?.focus();
}, [selection.mode, selection.exampleName]);
const fetchPdfBlob = useCallback(async (spec: Spec, signal?: AbortSignal) => {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec }),
signal,
});
if (!res.ok) throw new Error("Failed to generate PDF");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const prev = pdfUrlRef.current;
pdfUrlRef.current = url;
setPdfUrl(url);
if (prev) URL.revokeObjectURL(prev);
}, []);
// Progressive PDF 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);
fetchPdfBlob(spec)
.catch(() => {})
.finally(() => setRefreshing(false));
}, PDF_REFRESH_INTERVAL_MS);
return () => clearInterval(interval);
}, [generating, fetchPdfBlob]);
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 fetchPdfBlob(finalSpec);
} catch (e) {
if (controller.signal.aborted) return;
setError(e instanceof Error ? e.message : "Something went wrong");
setGenerating(false);
}
}, [prompt, selection, currentExample, fetchPdfBlob]);
const handleStop = useCallback(() => {
abortRef.current?.abort();
setGenerating(false);
if (isRenderableSpec(generatedSpec)) {
fetchPdfBlob(generatedSpec).catch(() => {});
}
}, [generatedSpec, fetchPdfBlob]);
const select = (next: Selection) => {
abortRef.current?.abort();
setSelection(next);
setGeneratedSpec(null);
setPdfUrl(null);
setError(null);
setPrompt("");
setGenerating(false);
setExamplesSheetOpen(false);
};
const handleDownload = async () => {
if (!activeSpec) return;
if (generatedSpec) {
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec: generatedSpec, download: true }),
});
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "document.pdf";
a.click();
URL.revokeObjectURL(url);
} else if (selection.mode === "example") {
window.open(
`/api/pdf?name=${selection.exampleName}&download=1`,
"_blank",
);
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleGenerate();
}
},
[handleGenerate],
);
const jsonCode = activeSpec
? JSON.stringify(activeSpec, null, 2)
: "// select an example or generate a PDF";
// ---------------------------------------------------------------------------
// 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-pdf
</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 PDF 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: PDF 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">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<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 a PDF"
: "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">
{displayPdfUrl ? (
<iframe
key={displayPdfUrl}
src={displayPdfUrl}
className="h-full w-full border-none"
title="PDF preview"
/>
) : (
<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 a PDF</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 PDF you want..."
: `Modify the ${currentExample?.label ?? "example"}...`
}
className="w-full bg-background text-base resize-none outline-none placeholder:text-muted-foreground/50"
rows={2}
/>
<div className="flex justify-between items-center mt-2">
<span className="text-[11px] text-muted-foreground">
{selection.mode === "example" && currentExample
? currentExample.label
: "scratch"}
</span>
{generating ? (
<button
onClick={handleStop}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
aria-label="Stop"
>
<Square className="h-3 w-3" fill="currentColor" />
</button>
) : (
<button
onClick={handleGenerate}
disabled={!prompt.trim()}
className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Generate"
>
<ArrowRight className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
<SheetContent side="left" className="w-80 p-0">
<SheetTitle className="sr-only">Examples</SheetTitle>
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
<p className="px-2 pt-2 pb-1 text-[11px] font-mono text-muted-foreground">
start
</p>
<button
onClick={() => select({ mode: "scratch" })}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "scratch"
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
From scratch
</button>
<p className="px-2 pt-3 pb-1 text-[11px] font-mono text-muted-foreground">
examples
</p>
{examples.map((ex) => (
<button
key={ex.name}
onClick={() =>
select({ mode: "example", exampleName: ex.name })
}
className={cn(
"w-full text-left px-3 py-2 rounded text-sm transition-colors",
selection.mode === "example" &&
selection.exampleName === ex.name
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground",
)}
>
<span className="font-medium">{ex.label}</span>
<p className="text-xs text-muted-foreground/70 mt-0.5 leading-snug">
{ex.description}
</p>
</button>
))}
</div>
</ScrollArea>
</SheetContent>
</Sheet>
</div>
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
@@ -0,0 +1,64 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };
@@ -0,0 +1,53 @@
"use client";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: ResizablePrimitive.GroupProps) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full aria-[orientation=vertical]:flex-col",
className,
)}
{...props}
/>
);
}
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: ResizablePrimitive.SeparatorProps & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
className,
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
);
}
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
@@ -0,0 +1,58 @@
"use client";
import * as React from "react";
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };
@@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import { Separator as SeparatorPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className,
)}
{...props}
/>
);
}
export { Separator };
+143
View File
@@ -0,0 +1,143 @@
"use client";
import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};
+91
View File
@@ -0,0 +1,91 @@
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className,
)}
{...props}
/>
);
}
const tabsListVariants = cva(
"rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
},
);
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className,
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
@@ -0,0 +1,18 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className,
)}
{...props}
/>
);
}
export { Textarea };
+8
View File
@@ -0,0 +1,8 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react-pdf/server";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
export const pdfCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
actions: {},
});
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
};
export default nextConfig;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "example-react-pdf",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "portless react-pdf-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-pdf": "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": {
"@repo/typescript-config": "workspace:*",
"@types/node": "^22.10.0",
"@types/react": "19.2.3",
"@types/react-dom": "19.2.3",
"shadcn": "^3.8.5",
"tw-animate-css": "^1.4.0",
"typescript": "^5.7.2"
}
}
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../packages/typescript-config/nextjs.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"declaration": false,
"declarationMap": false,
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
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.
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3002",
"dev": "portless remotion-demo.json-render next dev --turbopack",
"build": "next build",
"start": "next start",
"check-types": "tsc --noEmit"
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
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.
+1 -1
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --port 3001",
"dev": "portless stripe-api-demo.json-render next dev",
"build": "next build",
"start": "next start --port 3001"
},
@@ -1 +1,2 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
export const API_GENERATE_URL =
"http://stripe-api-demo.json-render.localhost:1355/api/generate";
@@ -1 +1,2 @@
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
export const API_GENERATE_URL =
"http://stripe-api-demo.json-render.localhost:1355/api/generate";
+7
View File
@@ -1,5 +1,12 @@
# @json-render/codegen
## 0.8.0
### Patch Changes
- Updated dependencies [09376db]
- @json-render/core@0.8.0
## 0.7.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.7.0",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+27
View File
@@ -1,5 +1,32 @@
# @json-render/core
## 0.8.0
### Minor Changes
- 09376db: New `@json-render/react-pdf` package for generating PDF documents from JSON specs.
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by `@react-pdf/renderer`. Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
- `renderToBuffer(spec)` — render a spec to an in-memory PDF buffer
- `renderToStream(spec)` — render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` — render directly to a file on disk
- `defineRegistry` / `createRenderer` — same API as `@json-render/react` for custom components
- `standardComponentDefinitions` — Zod-based catalog definitions (server-safe via `@json-render/react-pdf/catalog`)
- `standardComponents` — React PDF implementations for all standard components
- Server-safe import via `@json-render/react-pdf/server`
Standard components:
- **Document structure**: Document, Page
- **Layout**: View, Row, Column
- **Content**: Heading, Text, Image, Link
- **Data**: Table, List
- **Decorative**: Divider, Spacer
- **Page-level**: PageNumber
Includes full context support: state management, visibility conditions, actions, validation, and repeat scopes — matching the capabilities of `@json-render/react`.
## 0.7.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.7.0",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-native
## 0.8.0
### Patch Changes
- Updated dependencies [09376db]
- @json-render/core@0.8.0
## 0.7.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.7.0",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+33
View File
@@ -0,0 +1,33 @@
# @json-render/react-pdf
## 0.8.0
### Minor Changes
- 09376db: New `@json-render/react-pdf` package for generating PDF documents from JSON specs.
### New: `@json-render/react-pdf`
PDF renderer for json-render, powered by `@react-pdf/renderer`. Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
- `renderToBuffer(spec)` — render a spec to an in-memory PDF buffer
- `renderToStream(spec)` — render to a readable stream (pipe to HTTP response)
- `renderToFile(spec, path)` — render directly to a file on disk
- `defineRegistry` / `createRenderer` — same API as `@json-render/react` for custom components
- `standardComponentDefinitions` — Zod-based catalog definitions (server-safe via `@json-render/react-pdf/catalog`)
- `standardComponents` — React PDF implementations for all standard components
- Server-safe import via `@json-render/react-pdf/server`
Standard components:
- **Document structure**: Document, Page
- **Layout**: View, Row, Column
- **Content**: Heading, Text, Image, Link
- **Data**: Table, List
- **Decorative**: Divider, Spacer
- **Page-level**: PageNumber
Includes full context support: state management, visibility conditions, actions, validation, and repeat scopes — matching the capabilities of `@json-render/react`.
### Patch Changes
- Updated dependencies [09376db]
- @json-render/core@0.8.0
+167
View File
@@ -0,0 +1,167 @@
# @json-render/react-pdf
React PDF renderer for `@json-render/core`. Generate PDF documents from JSON specs using `@react-pdf/renderer`.
## Install
```bash
npm install @json-render/core @json-render/react-pdf
```
## Quick Start
### Render a spec to a PDF buffer
```typescript
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);
```
### With a custom catalog
```typescript
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToBuffer } from "@json-render/react-pdf";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Badge: {
props: z.object({
label: z.string(),
color: z.string().nullable(),
}),
slots: [],
description: "A colored badge label",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Badge: ({ props }) => (
<View style={{ backgroundColor: props.color ?? "#e5e7eb", padding: 4, borderRadius: 4 }}>
<Text style={{ fontSize: 10 }}>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });
```
## Standard Components
### Document Structure
| Component | Description |
|-----------|-------------|
| `Document` | Top-level PDF wrapper. Must be the root element. |
| `Page` | A page with size (A4, LETTER, etc.), orientation, and margins. |
### Layout
| Component | Description |
|-----------|-------------|
| `View` | Generic container with padding, margin, background, border. |
| `Row` | Horizontal flex layout with gap, align, justify. |
| `Column` | Vertical flex layout with gap, align, justify. |
### Content
| Component | Description |
|-----------|-------------|
| `Heading` | h1-h4 heading text. |
| `Text` | Body text with fontSize, color, weight, alignment. |
| `Image` | Image from URL or base64. |
| `Link` | Hyperlink with text and href. |
### Data
| Component | Description |
|-----------|-------------|
| `Table` | Data table with typed columns and string rows. |
| `List` | Ordered or unordered list. |
### Decorative
| Component | Description |
|-----------|-------------|
| `Divider` | Horizontal line separator. |
| `Spacer` | Empty vertical space. |
### Page-Level
| Component | Description |
|-----------|-------------|
| `PageNumber` | Renders current page number and total pages. |
## Server-Side APIs
```typescript
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
// Render to an in-memory Buffer
const buffer = await renderToBuffer(spec);
// Render to a readable stream (pipe to HTTP response)
const stream = await renderToStream(spec);
stream.pipe(res);
// Render directly to a file
await renderToFile(spec, "./output.pdf");
```
All render functions accept an optional second argument with:
- `registry` - Custom component registry (merged with standard components)
- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
- `handlers` - Action handlers
## Server-Safe Import
Import schema and catalog definitions without pulling in React:
```typescript
import { schema, standardComponentDefinitions } from "@json-render/react-pdf/server";
```
## License
Apache-2.0
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@json-render/react-pdf",
"version": "0.8.0",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
"json",
"pdf",
"react-pdf",
"ai",
"generative-ui",
"llm",
"renderer",
"documents"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/react-pdf"
},
"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-pdf/renderer": "^4.3.2"
},
"devDependencies": {
"@repo/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"
}
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from "react";
import type {
Catalog,
InferCatalogComponents,
InferComponentProps,
StateModel,
} from "@json-render/core";
export type { StateModel };
// =============================================================================
// State Types
// =============================================================================
export type SetState = (
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
) => void;
// =============================================================================
// Component Types
// =============================================================================
export interface ComponentContext<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> {
props: InferComponentProps<C, K>;
children?: ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}
export type ComponentFn<
C extends Catalog,
K extends keyof InferCatalogComponents<C>,
> = (ctx: ComponentContext<C, K>) => ReactNode;
export type Components<C extends Catalog> = {
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
};
+294
View File
@@ -0,0 +1,294 @@
import { z } from "zod";
/**
* Standard component definitions for React PDF catalogs.
*
* These define the available PDF components with their Zod prop schemas.
* All components render using @react-pdf/renderer primitives.
*/
export const standardComponentDefinitions = {
// ==========================================================================
// Document Structure
// ==========================================================================
Document: {
props: z.object({
title: z.string().nullable(),
author: z.string().nullable(),
subject: z.string().nullable(),
}),
slots: ["default"],
description:
"Top-level PDF document wrapper. Must be the root element. Children must be Page components.",
example: { title: "Invoice #1234", author: "Acme Corp" },
},
Page: {
props: z.object({
size: z.enum(["A4", "A3", "A5", "LETTER", "LEGAL", "TABLOID"]).nullable(),
orientation: z.enum(["portrait", "landscape"]).nullable(),
marginTop: z.number().nullable(),
marginBottom: z.number().nullable(),
marginLeft: z.number().nullable(),
marginRight: z.number().nullable(),
backgroundColor: z.string().nullable(),
}),
slots: ["default"],
description:
"A page in the PDF document. Set size and orientation. Children are laid out vertically by default.",
example: { size: "A4", orientation: "portrait" },
},
// ==========================================================================
// Layout Components
// ==========================================================================
View: {
props: z.object({
padding: z.number().nullable(),
paddingTop: z.number().nullable(),
paddingBottom: z.number().nullable(),
paddingLeft: z.number().nullable(),
paddingRight: z.number().nullable(),
margin: z.number().nullable(),
backgroundColor: z.string().nullable(),
borderWidth: z.number().nullable(),
borderColor: z.string().nullable(),
borderRadius: z.number().nullable(),
flex: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
}),
slots: ["default"],
description:
"Generic container for grouping elements. Supports padding, margin, background, border, and flex alignment.",
example: { padding: 10, backgroundColor: "#f9f9f9", alignItems: "center" },
},
Row: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
wrap: z.boolean().nullable(),
}),
slots: ["default"],
description:
"Horizontal flex layout. Use for placing elements side by side.",
example: { gap: 10, alignItems: "center" },
},
Column: {
props: z.object({
gap: z.number().nullable(),
alignItems: z
.enum(["flex-start", "center", "flex-end", "stretch"])
.nullable(),
justifyContent: z
.enum([
"flex-start",
"center",
"flex-end",
"space-between",
"space-around",
])
.nullable(),
padding: z.number().nullable(),
flex: z.number().nullable(),
}),
slots: ["default"],
description:
"Vertical flex layout. Use for stacking elements top to bottom.",
example: { gap: 8, padding: 10 },
},
// ==========================================================================
// Content Components
// ==========================================================================
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
}),
slots: [],
description:
"Heading text at various levels. h1 is largest, h4 is smallest.",
example: { text: "Invoice", level: "h1" },
},
Text: {
props: z.object({
text: z.string(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
fontWeight: z.enum(["normal", "bold"]).nullable(),
fontStyle: z.enum(["normal", "italic"]).nullable(),
lineHeight: z.number().nullable(),
}),
slots: [],
description:
"Body text with configurable size, color, weight, and alignment.",
example: { text: "Thank you for your business." },
},
Image: {
props: z.object({
src: z.string(),
width: z.number().nullable(),
height: z.number().nullable(),
objectFit: z.enum(["contain", "cover", "fill", "none"]).nullable(),
}),
slots: [],
description:
"Image from a URL. Specify width and/or height to control size. For placeholder/stock images use https://picsum.photos/{width}/{height}?random={n} where {n} is a unique number per image.",
example: {
src: "https://picsum.photos/400/300?random=1",
width: 400,
height: 300,
},
},
Link: {
props: z.object({
text: z.string(),
href: z.string(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
}),
slots: [],
description: "Hyperlink with visible text and a URL.",
example: { text: "Visit our website", href: "https://example.com" },
},
// ==========================================================================
// Data Components
// ==========================================================================
Table: {
props: z.object({
columns: z.array(
z.object({
header: z.string(),
width: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
}),
),
rows: z.array(z.array(z.string())),
headerBackgroundColor: z.string().nullable(),
headerTextColor: z.string().nullable(),
borderColor: z.string().nullable(),
fontSize: z.number().nullable(),
striped: z.boolean().nullable(),
}),
slots: [],
description:
"Data table with typed columns and rows. Each row is a string array matching the column count.",
example: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
},
List: {
props: z.object({
items: z.array(z.string()),
ordered: z.boolean().nullable(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
spacing: z.number().nullable(),
}),
slots: [],
description:
"Ordered or unordered list. Each item is rendered as a text line with a bullet or number.",
example: {
items: ["First item", "Second item", "Third item"],
ordered: false,
},
},
// ==========================================================================
// Decorative Components
// ==========================================================================
Divider: {
props: z.object({
color: z.string().nullable(),
thickness: z.number().nullable(),
marginTop: z.number().nullable(),
marginBottom: z.number().nullable(),
}),
slots: [],
description: "Horizontal line separator between content sections.",
example: { color: "#e5e7eb", thickness: 1 },
},
Spacer: {
props: z.object({
height: z.number().nullable(),
}),
slots: [],
description: "Empty vertical space between elements.",
example: { height: 20 },
},
// ==========================================================================
// Page-Level Components
// ==========================================================================
PageNumber: {
props: z.object({
format: z.string().nullable(),
fontSize: z.number().nullable(),
color: z.string().nullable(),
align: z.enum(["left", "center", "right"]).nullable(),
}),
slots: [],
description:
'Renders the current page number and total pages. Format uses {pageNumber} and {totalPages} placeholders, e.g. "Page {pageNumber} of {totalPages}". Default: "{pageNumber} / {totalPages}".',
example: {
format: "Page {pageNumber} of {totalPages}",
align: "center",
fontSize: 10,
},
},
};
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,459 @@
import React from "react";
import {
Document as PdfDocument,
Page as PdfPage,
View,
Text as PdfText,
Image as PdfImage,
Link as PdfLink,
StyleSheet,
} from "@react-pdf/renderer";
import type { ComponentRenderProps } from "../renderer";
import type { ComponentRegistry } from "../renderer";
import type { StandardComponentProps } from "../catalog";
const EMOJI_RE =
/[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F1E0}-\u{1F1FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{200D}\u{20E3}\u{E0020}-\u{E007F}]/gu;
function stripEmoji(text: string): string {
return text
.replace(EMOJI_RE, "")
.replace(/\s{2,}/g, " ")
.trim();
}
// =============================================================================
// Document Structure
// =============================================================================
function DocumentComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Document">>) {
const p = element.props;
return (
<PdfDocument
title={p.title ?? undefined}
author={p.author ?? undefined}
subject={p.subject ?? undefined}
>
{children}
</PdfDocument>
);
}
function PageComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Page">>) {
const p = element.props;
return (
<PdfPage
size={(p.size as any) ?? "A4"}
orientation={p.orientation ?? "portrait"}
style={{
paddingTop: p.marginTop ?? 40,
paddingBottom: p.marginBottom ?? 40,
paddingLeft: p.marginLeft ?? 40,
paddingRight: p.marginRight ?? 40,
backgroundColor: p.backgroundColor ?? undefined,
fontFamily: "Helvetica",
fontSize: 12,
}}
>
{children}
</PdfPage>
);
}
// =============================================================================
// Layout Components
// =============================================================================
function ViewComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"View">>) {
const p = element.props;
return (
<View
style={{
padding: p.padding ?? undefined,
paddingTop: p.paddingTop ?? undefined,
paddingBottom: p.paddingBottom ?? undefined,
paddingLeft: p.paddingLeft ?? undefined,
paddingRight: p.paddingRight ?? undefined,
margin: p.margin ?? undefined,
backgroundColor: p.backgroundColor ?? undefined,
borderWidth: p.borderWidth ?? undefined,
borderColor: p.borderColor ?? undefined,
borderRadius: p.borderRadius ?? undefined,
flex: p.flex ?? undefined,
alignItems: (p.alignItems as any) ?? undefined,
justifyContent: (p.justifyContent as any) ?? undefined,
}}
>
{children}
</View>
);
}
function RowComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Row">>) {
const p = element.props;
return (
<View
style={{
flexDirection: "row",
gap: p.gap ?? undefined,
alignItems: (p.alignItems as any) ?? undefined,
justifyContent: (p.justifyContent as any) ?? undefined,
padding: p.padding ?? undefined,
flex: p.flex ?? undefined,
flexWrap: p.wrap ? "wrap" : undefined,
}}
>
{children}
</View>
);
}
function ColumnComponent({
element,
children,
}: ComponentRenderProps<StandardComponentProps<"Column">>) {
const p = element.props;
return (
<View
style={{
flexDirection: "column",
gap: p.gap ?? undefined,
alignItems: (p.alignItems as any) ?? undefined,
justifyContent: (p.justifyContent as any) ?? undefined,
padding: p.padding ?? undefined,
flex: p.flex ?? undefined,
}}
>
{children}
</View>
);
}
// =============================================================================
// Content Components
// =============================================================================
const headingStyles = StyleSheet.create({
h1: { fontSize: 24, fontFamily: "Helvetica-Bold", marginBottom: 8 },
h2: { fontSize: 20, fontFamily: "Helvetica-Bold", marginBottom: 6 },
h3: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 4 },
h4: { fontSize: 14, fontFamily: "Helvetica-Bold", marginBottom: 4 },
});
function HeadingComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
const p = element.props;
const level = p.level ?? "h2";
return (
<PdfText
style={[
headingStyles[level],
{
color: p.color ?? undefined,
textAlign: p.align ?? "left",
},
]}
>
{stripEmoji(p.text)}
</PdfText>
);
}
function TextComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Text">>) {
const p = element.props;
return (
<PdfText
style={{
fontSize: p.fontSize ?? 12,
color: p.color ?? undefined,
textAlign: p.align ?? "left",
fontFamily:
p.fontWeight === "bold" && p.fontStyle === "italic"
? "Helvetica-BoldOblique"
: p.fontWeight === "bold"
? "Helvetica-Bold"
: p.fontStyle === "italic"
? "Helvetica-Oblique"
: "Helvetica",
lineHeight: p.lineHeight ?? undefined,
}}
>
{stripEmoji(p.text)}
</PdfText>
);
}
function ImageComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Image">>) {
const p = element.props;
return (
<PdfImage
src={p.src}
style={{
width: p.width ?? undefined,
height: p.height ?? undefined,
objectFit: p.objectFit ?? "contain",
}}
/>
);
}
function LinkComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Link">>) {
const p = element.props;
return (
<PdfLink
src={p.href}
style={{
fontSize: p.fontSize ?? 12,
color: p.color ?? "#2563eb",
textDecoration: "underline",
}}
>
{stripEmoji(p.text)}
</PdfLink>
);
}
// =============================================================================
// Data Components
// =============================================================================
const tableStyles = StyleSheet.create({
table: {
width: "100%",
},
row: {
flexDirection: "row",
},
cell: {
padding: 6,
},
headerCell: {
padding: 6,
fontFamily: "Helvetica-Bold",
},
bottomBorder: {
borderBottomWidth: 1,
},
});
function TableComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Table">>) {
const p = element.props;
const borderColor = p.borderColor ?? "#e5e7eb";
const fontSize = p.fontSize ?? 10;
const defaultWidth = `${100 / p.columns.length}%`;
return (
<View style={tableStyles.table}>
{/* Header */}
<View
style={[
tableStyles.row,
tableStyles.bottomBorder,
{
borderBottomColor: borderColor,
backgroundColor: p.headerBackgroundColor ?? "#f3f4f6",
},
]}
>
{p.columns.map((col, i) => (
<View
key={i}
style={[
tableStyles.headerCell,
{ width: col.width ?? defaultWidth },
]}
>
<PdfText
style={{
fontSize,
color: p.headerTextColor ?? "#111827",
textAlign: col.align ?? "left",
}}
>
{stripEmoji(col.header)}
</PdfText>
</View>
))}
</View>
{/* Rows */}
{p.rows.map((row, rowIndex) => (
<View
key={rowIndex}
style={[
tableStyles.row,
tableStyles.bottomBorder,
{
borderBottomColor: borderColor,
backgroundColor:
p.striped && rowIndex % 2 === 1 ? "#f9fafb" : undefined,
},
]}
>
{p.columns.map((col, colIndex) => (
<View
key={colIndex}
style={[tableStyles.cell, { width: col.width ?? defaultWidth }]}
>
<PdfText
style={{
fontSize,
textAlign: col.align ?? "left",
}}
>
{stripEmoji(row[colIndex] ?? "")}
</PdfText>
</View>
))}
</View>
))}
</View>
);
}
function ListComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"List">>) {
const p = element.props;
const fontSize = p.fontSize ?? 12;
const spacing = p.spacing ?? 4;
return (
<View style={{ gap: spacing }}>
{p.items.map((item, index) => (
<View key={index} style={{ flexDirection: "row", gap: 6 }}>
<PdfText
style={{
fontSize,
color: p.color ?? undefined,
width: p.ordered ? 20 : 12,
}}
>
{p.ordered ? `${index + 1}.` : "\u2022"}
</PdfText>
<PdfText
style={{
fontSize,
color: p.color ?? undefined,
flex: 1,
}}
>
{stripEmoji(item)}
</PdfText>
</View>
))}
</View>
);
}
// =============================================================================
// Decorative Components
// =============================================================================
function DividerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Divider">>) {
const p = element.props;
return (
<View
style={{
borderBottomWidth: p.thickness ?? 1,
borderBottomColor: p.color ?? "#e5e7eb",
marginTop: p.marginTop ?? 8,
marginBottom: p.marginBottom ?? 8,
}}
/>
);
}
function SpacerComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"Spacer">>) {
const p = element.props;
return <View style={{ height: p.height ?? 20 }} />;
}
// =============================================================================
// Page-Level Components
// =============================================================================
function PageNumberComponent({
element,
}: ComponentRenderProps<StandardComponentProps<"PageNumber">>) {
const p = element.props;
const format = p.format ?? "{pageNumber} / {totalPages}";
return (
<PdfText
style={{
fontSize: p.fontSize ?? 10,
color: p.color ?? "#6b7280",
textAlign: p.align ?? "center",
}}
render={({ pageNumber, totalPages }) =>
stripEmoji(
format
.replace("{pageNumber}", String(pageNumber))
.replace("{totalPages}", String(totalPages)),
)
}
fixed
/>
);
}
// =============================================================================
// Registry
// =============================================================================
export const standardComponents: ComponentRegistry = {
Document: DocumentComponent,
Page: PageComponent,
View: ViewComponent,
Row: RowComponent,
Column: ColumnComponent,
Heading: HeadingComponent,
Text: TextComponent,
Image: ImageComponent,
Link: LinkComponent,
Table: TableComponent,
List: ListComponent,
Divider: DividerComponent,
Spacer: SpacerComponent,
PageNumber: PageNumberComponent,
};
+283
View File
@@ -0,0 +1,283 @@
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";
let idCounter = 0;
function generateUniqueId(): string {
idCounter += 1;
return `${Date.now()}-${idCounter}`;
}
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 PDF context. PDFs are non-interactive,
* so confirmations are not rendered.
*/
export function ConfirmDialog(_props: ConfirmDialogProps) {
return null;
}
@@ -0,0 +1,26 @@
import React, { createContext, useContext, type ReactNode } from "react";
export interface RepeatScopeValue {
item: unknown;
index: number;
basePath: string;
}
const RepeatScopeContext = createContext<RepeatScopeValue | null>(null);
export function RepeatScopeProvider({
item,
index,
basePath,
children,
}: RepeatScopeValue & { children: ReactNode }) {
return (
<RepeatScopeContext.Provider value={{ item, index, basePath }}>
{children}
</RepeatScopeContext.Provider>
);
}
export function useRepeatScope(): RepeatScopeValue | null {
return useContext(RepeatScopeContext);
}
+117
View File
@@ -0,0 +1,117 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
useEffect,
useRef,
type ReactNode,
} from "react";
import { getByPath, setByPath, type StateModel } from "@json-render/core";
export interface StateContextValue {
state: StateModel;
get: (path: string) => unknown;
set: (path: string, value: unknown) => void;
update: (updates: Record<string, unknown>) => void;
}
const StateContext = createContext<StateContextValue | null>(null);
export interface StateProviderProps {
initialState?: StateModel;
onStateChange?: (path: string, value: unknown) => void;
children: ReactNode;
}
export function StateProvider({
initialState = {},
onStateChange,
children,
}: StateProviderProps) {
const [state, setStateInternal] = useState<StateModel>(initialState);
const stateRef = useRef(state);
stateRef.current = state;
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
useEffect(() => {
const newJson = JSON.stringify(initialState);
if (newJson !== initialStateJsonRef.current) {
initialStateJsonRef.current = newJson;
if (initialState && Object.keys(initialState).length > 0) {
setStateInternal((prev) => ({ ...prev, ...initialState }));
}
}
}, [initialState]);
const get = useCallback(
(path: string) => getByPath(stateRef.current, path),
[],
);
const set = useCallback(
(path: string, value: unknown) => {
setStateInternal((prev) => {
const next = { ...prev };
setByPath(next, path, value);
return next;
});
onStateChange?.(path, value);
},
[onStateChange],
);
const update = useCallback(
(updates: Record<string, unknown>) => {
const entries = Object.entries(updates);
setStateInternal((prev) => {
const next = { ...prev };
for (const [path, value] of entries) {
setByPath(next, path, value);
}
return next;
});
for (const [path, value] of entries) {
onStateChange?.(path, value);
}
},
[onStateChange],
);
const value = useMemo<StateContextValue>(
() => ({ state, get, set, update }),
[state, get, set, update],
);
return (
<StateContext.Provider value={value}>{children}</StateContext.Provider>
);
}
export function useStateStore(): StateContextValue {
const ctx = useContext(StateContext);
if (!ctx) {
throw new Error("useStateStore must be used within a StateProvider");
}
return ctx;
}
export function useStateValue<T>(path: string): T | undefined {
const { state } = useStateStore();
return getByPath(state, path) as T | undefined;
}
export function useStateBinding<T>(
path: string,
): [T | undefined, (value: T) => void] {
const { state, set } = useStateStore();
const value = getByPath(state, path) as T | undefined;
const setValue = useCallback(
(newValue: T) => set(path, newValue),
[path, set],
);
return [value, setValue];
}

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