Compare commits

..
Author SHA1 Message Date
Chris Tate b28fbc3bb9 fixes 2026-02-18 13:10:14 -06:00
Chris Tate b6e4ca0584 fixes 2026-02-18 12:42:54 -06:00
Chris Tate d6207dc093 fixes actions 2026-02-18 12:39:49 -06:00
Chris Tate 1a36ccf7ae fixes 2026-02-18 12:29:13 -06:00
Chris Tate de81bf56ff fix data 2026-02-18 12:24:20 -06:00
Chris Tate 51d02822c5 progressive 2026-02-18 12:19:32 -06:00
Chris Tate 91ca8008dc fixes 2026-02-18 12:16:58 -06:00
Chris Tate c86e5455e3 refactor 2026-02-18 12:07:48 -06:00
Chris Tate ca68112032 stripe cleanup 2026-02-18 11:56:56 -06:00
Chris Tate 3a448f6366 fixes 2026-02-18 11:48:07 -06:00
Chris Tate c869713cad full screen flag 2026-02-18 11:42:34 -06:00
Chris Tate 7f4684c562 stripe cleanup 2026-02-18 11:32:13 -06:00
60 changed files with 59 additions and 8227 deletions
-3
View File
@@ -43,9 +43,6 @@ yarn-error.log*
# opensrc - source code for packages
opensrc/
# json-studio (separate repo)
json-studio/
.env*.local
# Stripe apps (generated from template + build artifacts)
-37
View File
@@ -12,8 +12,6 @@ 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?
@@ -113,7 +111,6 @@ 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
@@ -224,40 +221,6 @@ 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)
+7 -38
View File
@@ -191,44 +191,13 @@ In chat mode, the prompt instructs the AI to respond conversationally first, the
## Which Mode?
<div className="my-6 overflow-x-auto">
<table className="mdx-table w-full text-sm border-collapse">
<thead>
<tr>
<th></th>
<th>Generate</th>
<th>Chat</th>
</tr>
</thead>
<tbody>
<tr>
<td>Output</td>
<td>JSONL only</td>
<td>Text + JSONL</td>
</tr>
<tr>
<td>Text-only replies</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>System prompt</td>
<td><code>catalog.prompt()</code></td>
<td><code>{"catalog.prompt({ mode: \"chat\" })"}</code></td>
</tr>
<tr>
<td>Stream utility</td>
<td><code>useUIStream</code></td>
<td><code>pipeJsonRender</code> + <code>useJsonRenderMessage</code></td>
</tr>
<tr>
<td>Use case</td>
<td>Playgrounds, builders</td>
<td>Chatbots, copilots</td>
</tr>
</tbody>
</table>
</div>
| | Generate | Chat |
|---|---|---|
| Output | JSONL only | Text + JSONL |
| Text-only replies | No | Yes |
| System prompt | `catalog.prompt()` | `catalog.prompt({ mode: "chat" })` |
| Stream utility | `useUIStream` | `pipeJsonRender` + `useJsonRenderMessage` |
| Use case | Playgrounds, builders | Chatbots, copilots |
Learn more in the [Generation Modes](/docs/generation-modes) guide.
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/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
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-105
View File
@@ -1,105 +0,0 @@
@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
@@ -1,34 +0,0 @@
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
@@ -1,72 +0,0 @@
"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
@@ -1,12 +0,0 @@
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
@@ -1,511 +0,0 @@
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
@@ -1,16 +0,0 @@
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
@@ -1,29 +0,0 @@
"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
@@ -1,6 +0,0 @@
/// <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
@@ -1,5 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {};
export default nextConfig;
-40
View File
@@ -1,40 +0,0 @@
{
"name": "example-no-ai",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3003",
"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
@@ -1,5 +0,0 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
-13
View File
@@ -1,13 +0,0 @@
{
"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
@@ -1,5 +0,0 @@
# 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
@@ -1,35 +0,0 @@
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
@@ -1,46 +0,0 @@
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
@@ -1,130 +0,0 @@
@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
@@ -1,23 +0,0 @@
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
@@ -1,615 +0,0 @@
"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
@@ -1,23 +0,0 @@
{
"$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": {}
}
@@ -1,64 +0,0 @@
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 };
@@ -1,53 +0,0 @@
"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 };
@@ -1,58 +0,0 @@
"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 };
@@ -1,28 +0,0 @@
"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
@@ -1,143 +0,0 @@
"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
@@ -1,91 +0,0 @@
"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 };
@@ -1,18 +0,0 @@
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
@@ -1,8 +0,0 @@
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
@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-6
View File
@@ -1,6 +0,0 @@
/// <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
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
serverExternalPackages: ["@react-pdf/renderer", "@json-render/react-pdf"],
};
export default nextConfig;
-39
View File
@@ -1,39 +0,0 @@
{
"name": "example-react-pdf",
"version": "0.1.0",
"type": "module",
"private": true,
"scripts": {
"dev": "next dev --turbopack --port 3005",
"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
@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-13
View File
@@ -1,13 +0,0 @@
{
"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/types/routes.d.ts";
import "./.next/dev/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
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-167
View File
@@ -1,167 +0,0 @@
# @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
@@ -1,77 +0,0 @@
{
"name": "@json-render/react-pdf",
"version": "0.7.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
@@ -1,41 +0,0 @@
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
@@ -1,294 +0,0 @@
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"]>;
@@ -1 +0,0 @@
export { standardComponents } from "./standard";
@@ -1,459 +0,0 @@
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
@@ -1,283 +0,0 @@
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;
}
@@ -1,26 +0,0 @@
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
@@ -1,117 +0,0 @@
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];
}
@@ -1,262 +0,0 @@
import React, {
createContext,
useContext,
useState,
useCallback,
useMemo,
type ReactNode,
} from "react";
import {
runValidation,
type ValidationConfig,
type ValidationFunction,
type ValidationResult,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface FieldValidationState {
touched: boolean;
validated: boolean;
result: ValidationResult | null;
}
export interface ValidationContextValue {
customFunctions: Record<string, ValidationFunction>;
fieldStates: Record<string, FieldValidationState>;
validate: (path: string, config: ValidationConfig) => ValidationResult;
touch: (path: string) => void;
clear: (path: string) => void;
validateAll: () => boolean;
registerField: (path: string, config: ValidationConfig) => void;
}
const ValidationContext = createContext<ValidationContextValue | null>(null);
export interface ValidationProviderProps {
customFunctions?: Record<string, ValidationFunction>;
children: ReactNode;
}
function dynamicArgsEqual(
a: Record<string, unknown> | undefined,
b: Record<string, unknown> | undefined,
): boolean {
if (a === b) return true;
if (!a || !b) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const va = a[key];
const vb = b[key];
if (va === vb) continue;
if (
typeof va === "object" &&
va !== null &&
typeof vb === "object" &&
vb !== null
) {
const sa = (va as Record<string, unknown>).$state;
const sb = (vb as Record<string, unknown>).$state;
if (typeof sa === "string" && sa === sb) continue;
}
return false;
}
return true;
}
function validationConfigEqual(
a: ValidationConfig,
b: ValidationConfig,
): boolean {
if (a === b) return true;
if (a.validateOn !== b.validateOn) return false;
const ac = a.checks ?? [];
const bc = b.checks ?? [];
if (ac.length !== bc.length) return false;
for (let i = 0; i < ac.length; i++) {
const ca = ac[i]!;
const cb = bc[i]!;
if (ca.type !== cb.type) return false;
if (ca.message !== cb.message) return false;
if (!dynamicArgsEqual(ca.args, cb.args)) return false;
}
return true;
}
export function ValidationProvider({
customFunctions = {},
children,
}: ValidationProviderProps) {
const { state } = useStateStore();
const [fieldStates, setFieldStates] = useState<
Record<string, FieldValidationState>
>({});
const [fieldConfigs, setFieldConfigs] = useState<
Record<string, ValidationConfig>
>({});
const registerField = useCallback(
(path: string, config: ValidationConfig) => {
setFieldConfigs((prev) => {
const existing = prev[path];
if (existing && validationConfigEqual(existing, config)) {
return prev;
}
return { ...prev, [path]: config };
});
},
[],
);
const validate = useCallback(
(path: string, config: ValidationConfig): ValidationResult => {
const segments = path.split("/").filter(Boolean);
let value: unknown = state;
for (const seg of segments) {
if (value != null && typeof value === "object") {
value = (value as Record<string, unknown>)[seg];
} else {
value = undefined;
break;
}
}
const result = runValidation(config, {
value,
stateModel: state,
customFunctions,
});
setFieldStates((prev) => ({
...prev,
[path]: {
touched: prev[path]?.touched ?? true,
validated: true,
result,
},
}));
return result;
},
[state, customFunctions],
);
const touch = useCallback((path: string) => {
setFieldStates((prev) => ({
...prev,
[path]: {
...prev[path],
touched: true,
validated: prev[path]?.validated ?? false,
result: prev[path]?.result ?? null,
},
}));
}, []);
const clear = useCallback((path: string) => {
setFieldStates((prev) => {
const { [path]: _, ...rest } = prev;
return rest;
});
}, []);
const validateAll = useCallback(() => {
let allValid = true;
for (const [path, config] of Object.entries(fieldConfigs)) {
const result = validate(path, config);
if (!result.valid) {
allValid = false;
}
}
return allValid;
}, [fieldConfigs, validate]);
const value = useMemo<ValidationContextValue>(
() => ({
customFunctions,
fieldStates,
validate,
touch,
clear,
validateAll,
registerField,
}),
[
customFunctions,
fieldStates,
validate,
touch,
clear,
validateAll,
registerField,
],
);
return (
<ValidationContext.Provider value={value}>
{children}
</ValidationContext.Provider>
);
}
export function useValidation(): ValidationContextValue {
const ctx = useContext(ValidationContext);
if (!ctx) {
throw new Error("useValidation must be used within a ValidationProvider");
}
return ctx;
}
export function useFieldValidation(
path: string,
config?: ValidationConfig,
): {
state: FieldValidationState;
validate: () => ValidationResult;
touch: () => void;
clear: () => void;
errors: string[];
isValid: boolean;
} {
const {
fieldStates,
validate: validateField,
touch: touchField,
clear: clearField,
registerField,
} = useValidation();
React.useEffect(() => {
if (config) {
registerField(path, config);
}
}, [path, config, registerField]);
const state = fieldStates[path] ?? {
touched: false,
validated: false,
result: null,
};
const validate = useCallback(
() => validateField(path, config ?? { checks: [] }),
[path, config, validateField],
);
const touch = useCallback(() => touchField(path), [path, touchField]);
const clear = useCallback(() => clearField(path), [path, clearField]);
return {
state,
validate,
touch,
clear,
errors: state.result?.errors ?? [],
isValid: state.result?.valid ?? true,
};
}
@@ -1,64 +0,0 @@
import React, {
createContext,
useContext,
useMemo,
type ReactNode,
} from "react";
import {
evaluateVisibility,
type VisibilityCondition,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import { useStateStore } from "./state";
export interface VisibilityContextValue {
isVisible: (condition: VisibilityCondition | undefined) => boolean;
ctx: CoreVisibilityContext;
}
const VisibilityContext = createContext<VisibilityContextValue | null>(null);
export interface VisibilityProviderProps {
children: ReactNode;
}
export function VisibilityProvider({ children }: VisibilityProviderProps) {
const { state } = useStateStore();
const ctx: CoreVisibilityContext = useMemo(
() => ({ stateModel: state }),
[state],
);
const isVisible = useMemo(
() => (condition: VisibilityCondition | undefined) =>
evaluateVisibility(condition, ctx),
[ctx],
);
const value = useMemo<VisibilityContextValue>(
() => ({ isVisible, ctx }),
[isVisible, ctx],
);
return (
<VisibilityContext.Provider value={value}>
{children}
</VisibilityContext.Provider>
);
}
export function useVisibility(): VisibilityContextValue {
const ctx = useContext(VisibilityContext);
if (!ctx) {
throw new Error("useVisibility must be used within a VisibilityProvider");
}
return ctx;
}
export function useIsVisible(
condition: VisibilityCondition | undefined,
): boolean {
const { isVisible } = useVisibility();
return isVisible(condition);
}
-92
View File
@@ -1,92 +0,0 @@
// Schema
export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
// Core types (re-exported for convenience)
export type { Spec } from "@json-render/core";
// Catalog-aware types
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
// Contexts
export {
StateProvider,
useStateStore,
useStateValue,
useStateBinding,
type StateContextValue,
type StateProviderProps,
} from "./contexts/state";
export {
VisibilityProvider,
useVisibility,
useIsVisible,
type VisibilityContextValue,
type VisibilityProviderProps,
} from "./contexts/visibility";
export {
ActionProvider,
useActions,
useAction,
ConfirmDialog,
type ActionContextValue,
type ActionProviderProps,
type PendingConfirmation,
type ConfirmDialogProps,
} from "./contexts/actions";
export {
ValidationProvider,
useValidation,
useFieldValidation,
type ValidationContextValue,
type ValidationProviderProps,
type FieldValidationState,
} from "./contexts/validation";
export {
RepeatScopeProvider,
useRepeatScope,
type RepeatScopeValue,
} from "./contexts/repeat-scope";
// Renderer
export {
defineRegistry,
type DefineRegistryResult,
createRenderer,
type CreateRendererProps,
type ComponentMap,
Renderer,
JSONUIProvider,
type ComponentRenderProps,
type ComponentRenderer,
type ComponentRegistry,
type RendererProps,
type JSONUIProviderProps,
} from "./renderer";
// Standard components
export { standardComponents } from "./components";
// Server-side render functions
export {
renderToBuffer,
renderToStream,
renderToFile,
type RenderOptions,
} from "./render";
// Catalog definitions
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
-180
View File
@@ -1,180 +0,0 @@
import React from "react";
import {
renderToBuffer as pdfRenderToBuffer,
renderToStream as pdfRenderToStream,
render as pdfRender,
} from "@react-pdf/renderer";
import type { Spec, UIElement } from "@json-render/core";
import {
resolveElementProps,
evaluateVisibility,
getByPath,
type PropResolutionContext,
} from "@json-render/core";
import { standardComponents } from "./components/standard";
// Re-export the standard components for use in custom registries
export { standardComponents };
export type RenderComponentRegistry = Record<string, React.ComponentType<any>>;
export interface RenderOptions {
registry?: RenderComponentRegistry;
includeStandard?: boolean;
state?: Record<string, unknown>;
}
const noopEmit = () => {};
function renderElement(
elementKey: string,
spec: Spec,
registry: RenderComponentRegistry,
stateModel: Record<string, unknown>,
repeatItem?: unknown,
repeatIndex?: number,
repeatBasePath?: string,
): React.ReactElement | null {
const element = spec.elements[elementKey];
if (!element) return null;
const ctx: PropResolutionContext = {
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
};
if (element.visible !== undefined) {
if (!evaluateVisibility(element.visible, ctx)) {
return null;
}
}
const resolvedProps = resolveElementProps(
element.props as Record<string, unknown>,
ctx,
);
const resolvedElement: UIElement = { ...element, props: resolvedProps };
const Component = registry[resolvedElement.type];
if (!Component) return null;
if (resolvedElement.repeat) {
const items =
(getByPath(stateModel, resolvedElement.repeat.statePath) as
| unknown[]
| undefined) ?? [];
const fragments = items.map((item, index) => {
const key =
resolvedElement.repeat!.key && typeof item === "object" && item !== null
? String(
(item as Record<string, unknown>)[resolvedElement.repeat!.key!] ??
index,
)
: String(index);
const childPath = `${resolvedElement.repeat!.statePath}/${index}`;
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
item,
index,
childPath,
),
);
return (
<Component key={key} element={resolvedElement} emit={noopEmit}>
{children}
</Component>
);
});
return <>{fragments}</>;
}
const children = resolvedElement.children?.map((childKey) =>
renderElement(
childKey,
spec,
registry,
stateModel,
repeatItem,
repeatIndex,
repeatBasePath,
),
);
return (
<Component key={elementKey} element={resolvedElement} emit={noopEmit}>
{children && children.length > 0 ? children : undefined}
</Component>
);
}
function buildDocument(
spec: Spec,
options: RenderOptions = {},
): React.ReactElement {
const {
registry: customRegistry,
includeStandard = true,
state = {},
} = options;
const mergedState: Record<string, unknown> = {
...spec.state,
...state,
};
const registry: RenderComponentRegistry = {
...(includeStandard ? standardComponents : {}),
...customRegistry,
};
const root = renderElement(spec.root, spec, registry, mergedState);
return root ?? <></>;
}
/**
* Render a json-render spec to a PDF buffer.
*
* This is a standalone server-side function that resolves the spec tree
* without React hooks or contexts, making it safe to import in Next.js
* route handlers and other server-only environments.
*/
export async function renderToBuffer(
spec: Spec,
options?: RenderOptions,
): Promise<Uint8Array> {
const document = buildDocument(spec, options);
return pdfRenderToBuffer(document as any);
}
/**
* Render a json-render spec to a PDF readable stream.
*/
export async function renderToStream(
spec: Spec,
options?: RenderOptions,
): Promise<ReadableStream> {
const document = buildDocument(spec, options);
return pdfRenderToStream(document as any);
}
/**
* Render a json-render spec to a PDF file on disk.
*/
export async function renderToFile(
spec: Spec,
filePath: string,
options?: RenderOptions,
): Promise<void> {
const document = buildDocument(spec, options);
await pdfRender(document as any, filePath);
}
-503
View File
@@ -1,503 +0,0 @@
import React, {
type ComponentType,
type ErrorInfo,
type ReactNode,
useCallback,
useMemo,
} from "react";
import type {
UIElement,
Spec,
ActionBinding,
Catalog,
SchemaDefinition,
} from "@json-render/core";
import {
resolveElementProps,
resolveBindings,
resolveActionParam,
evaluateVisibility,
getByPath,
type PropResolutionContext,
type VisibilityContext as CoreVisibilityContext,
} from "@json-render/core";
import type { Components, SetState, StateModel } from "./catalog-types";
import { useIsVisible, useVisibility } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useStateStore } from "./contexts/state";
import { StateProvider } from "./contexts/state";
import { VisibilityProvider } from "./contexts/visibility";
import { ActionProvider } from "./contexts/actions";
import { ValidationProvider } from "./contexts/validation";
import { standardComponents } from "./components/standard";
import { RepeatScopeProvider, useRepeatScope } from "./contexts/repeat-scope";
// =============================================================================
// Types
// =============================================================================
export interface ComponentRenderProps<P = Record<string, unknown>> {
element: UIElement<string, P>;
children?: ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}
export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
ComponentRenderProps<P>
>;
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
export interface RendererProps {
spec: Spec | null;
registry?: ComponentRegistry;
includeStandard?: boolean;
loading?: boolean;
fallback?: ComponentRenderer;
}
// =============================================================================
// ElementErrorBoundary
// =============================================================================
interface ElementErrorBoundaryProps {
elementType: string;
children: ReactNode;
}
interface ElementErrorBoundaryState {
hasError: boolean;
}
class ElementErrorBoundary extends React.Component<
ElementErrorBoundaryProps,
ElementErrorBoundaryState
> {
constructor(props: ElementErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(): ElementErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error(
`[json-render/react-pdf] Rendering error in <${this.props.elementType}>:`,
error,
info.componentStack,
);
}
render() {
if (this.state.hasError) {
return null;
}
return this.props.children;
}
}
// =============================================================================
// ElementRenderer
// =============================================================================
interface ElementRendererProps {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}
const ElementRenderer = React.memo(function ElementRenderer({
element,
spec,
registry,
loading,
fallback,
}: ElementRendererProps) {
const repeatScope = useRepeatScope();
const { ctx } = useVisibility();
const { execute } = useActions();
const fullCtx: PropResolutionContext = useMemo(
() =>
repeatScope
? {
...ctx,
repeatItem: repeatScope.item,
repeatIndex: repeatScope.index,
repeatBasePath: repeatScope.basePath,
}
: ctx,
[ctx, repeatScope],
);
const isVisible =
element.visible === undefined
? true
: evaluateVisibility(element.visible, fullCtx);
const onBindings = element.on;
const emit = useCallback(
(eventName: string) => {
const binding = onBindings?.[eventName];
if (!binding) return;
const actionBindings = Array.isArray(binding) ? binding : [binding];
for (const b of actionBindings) {
if (!b.params) {
execute(b);
continue;
}
const resolved: Record<string, unknown> = {};
for (const [key, val] of Object.entries(b.params)) {
resolved[key] = resolveActionParam(val, fullCtx);
}
execute({ ...b, params: resolved });
}
},
[onBindings, execute, fullCtx],
);
if (!isVisible) {
return null;
}
const rawProps = element.props as Record<string, unknown>;
const elementBindings = resolveBindings(rawProps, fullCtx);
const resolvedProps = resolveElementProps(rawProps, fullCtx);
const resolvedElement =
resolvedProps !== element.props
? { ...element, props: resolvedProps }
: element;
const Component = registry[resolvedElement.type] ?? fallback;
if (!Component) {
console.warn(
`[json-render/react-pdf] No renderer for component type: ${resolvedElement.type}`,
);
return null;
}
const children = resolvedElement.repeat ? (
<RepeatChildren
element={resolvedElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
) : (
resolvedElement.children?.map((childKey) => {
const childElement = spec.elements[childKey];
if (!childElement) {
if (!loading) {
console.warn(
`[json-render/react-pdf] Missing element "${childKey}" referenced as child of "${resolvedElement.type}".`,
);
}
return null;
}
return (
<ElementRenderer
key={childKey}
element={childElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
})
);
return (
<ElementErrorBoundary elementType={resolvedElement.type}>
<Component
element={resolvedElement}
emit={emit}
bindings={elementBindings}
loading={loading}
>
{children}
</Component>
</ElementErrorBoundary>
);
});
// =============================================================================
// RepeatChildren
// =============================================================================
function RepeatChildren({
element,
spec,
registry,
loading,
fallback,
}: {
element: UIElement;
spec: Spec;
registry: ComponentRegistry;
loading?: boolean;
fallback?: ComponentRenderer;
}) {
const { state } = useStateStore();
const repeat = element.repeat!;
const statePath = repeat.statePath;
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
return (
<>
{items.map((itemValue, index) => {
const key =
repeat.key && typeof itemValue === "object" && itemValue !== null
? String(
(itemValue as Record<string, unknown>)[repeat.key] ?? index,
)
: String(index);
return (
<RepeatScopeProvider
key={key}
item={itemValue}
index={index}
basePath={`${statePath}/${index}`}
>
{element.children?.map((childKey) => {
const childElement = spec.elements[childKey];
if (!childElement) {
if (!loading) {
console.warn(
`[json-render/react-pdf] Missing element "${childKey}" referenced as child of "${element.type}" (repeat).`,
);
}
return null;
}
return (
<ElementRenderer
key={childKey}
element={childElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
})}
</RepeatScopeProvider>
);
})}
</>
);
}
// =============================================================================
// Renderer
// =============================================================================
export function Renderer({
spec,
registry: customRegistry,
includeStandard = true,
loading,
fallback,
}: RendererProps) {
const registry: ComponentRegistry = useMemo(
() => ({
...(includeStandard ? standardComponents : {}),
...customRegistry,
}),
[customRegistry, includeStandard],
);
if (!spec || !spec.root) {
return null;
}
const rootElement = spec.elements[spec.root];
if (!rootElement) {
return null;
}
return (
<ElementRenderer
element={rootElement}
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
);
}
// =============================================================================
// JSONUIProvider
// =============================================================================
export interface JSONUIProviderProps {
registry?: ComponentRegistry;
initialState?: Record<string, unknown>;
handlers?: Record<
string,
(params: Record<string, unknown>) => Promise<unknown> | unknown
>;
navigate?: (path: string) => void;
validationFunctions?: Record<
string,
(value: unknown, args?: Record<string, unknown>) => boolean
>;
onStateChange?: (path: string, value: unknown) => void;
children: ReactNode;
}
export function JSONUIProvider({
initialState,
handlers,
navigate,
validationFunctions,
onStateChange,
children,
}: JSONUIProviderProps) {
return (
<StateProvider initialState={initialState} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider handlers={handlers} navigate={navigate}>
<ValidationProvider customFunctions={validationFunctions}>
{children}
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
}
// =============================================================================
// defineRegistry
// =============================================================================
export interface DefineRegistryResult {
registry: ComponentRegistry;
}
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
emit: (event: string) => void;
bindings?: Record<string, string>;
loading?: boolean;
}) => React.ReactNode;
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
},
): DefineRegistryResult {
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
emit,
bindings,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
emit,
bindings,
loading,
});
};
}
}
return { registry };
}
// =============================================================================
// createRenderer
// =============================================================================
export interface CreateRendererProps {
spec: Spec | null;
state?: Record<string, unknown>;
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
onStateChange?: (path: string, value: unknown) => void;
loading?: boolean;
fallback?: ComponentRenderer;
}
export type ComponentMap<
TComponents extends Record<string, { props: unknown }>,
> = {
[K in keyof TComponents]: ComponentType<
ComponentRenderProps<
TComponents[K]["props"] extends { _output: infer O }
? O
: Record<string, unknown>
>
>;
};
export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): ComponentType<CreateRendererProps> {
const registry: ComponentRegistry =
components as unknown as ComponentRegistry;
return function CatalogRenderer({
spec,
state,
onAction,
onStateChange,
loading,
fallback,
}: CreateRendererProps) {
const actionHandlers = onAction
? new Proxy(
{} as Record<
string,
(params: Record<string, unknown>) => void | Promise<void>
>,
{
get: (_target, prop: string) => {
return (params: Record<string, unknown>) =>
onAction(prop, params);
},
has: () => true,
},
)
: undefined;
return (
<StateProvider initialState={state} onStateChange={onStateChange}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<ValidationProvider>
<Renderer
spec={spec}
registry={registry}
loading={loading}
fallback={fallback}
/>
</ValidationProvider>
</ActionProvider>
</VisibilityProvider>
</StateProvider>
);
};
}
-57
View File
@@ -1,57 +0,0 @@
import { defineSchema } from "@json-render/core";
/**
* The schema for @json-render/react-pdf
*
* Defines:
* - Spec: A flat tree of elements with keys, types, props, and children references
* - Catalog: Components with props schemas
*
* Reuses the same { root, elements } spec format as the React and React Native renderers.
*/
export const schema = defineSchema(
(s) => ({
spec: s.object({
root: s.string(),
elements: s.record(
s.object({
type: s.ref("catalog.components"),
props: s.propsOf("catalog.components"),
children: s.array(s.string()),
visible: s.any(),
}),
),
}),
catalog: s.object({
components: s.map({
props: s.zod(),
slots: s.array(s.string()),
description: s.string(),
example: s.any(),
}),
}),
}),
{
defaultRules: [
"The root element MUST be a Document component. Its children MUST be Page components.",
"Every Page must specify a size (e.g. 'A4', 'LETTER') and can set orientation, margins, and background color.",
"Use Row for horizontal layouts and Column for vertical layouts. Both support gap, align, and justify props.",
"Table columns must define header and optionally width and align. Rows is an array of string arrays matching the column count.",
"All text content must use Heading or Text components. Raw strings are not supported.",
"Image src must be a fully qualified URL. For placeholder or stock images, always use https://picsum.photos/{width}/{height}?random={n} (e.g. https://picsum.photos/400/300?random=1). Never use unsplash URLs directly.",
"PageNumber renders the current page number and total pages. Place it inside a Page.",
"NEVER use emoji characters in any text content. The PDF font (Helvetica) does not support emojis and they will render as garbled/overlapping characters. Use plain text descriptions instead (e.g. 'Phone:' not '📞', 'Email:' not '📧').",
"PAGE LAYOUT: Be conservative with content density. A portrait A4/LETTER page with 40pt margins fits roughly 700pt of content height. For single-page documents (flyers, posters, one-pagers), keep all content on one page using smaller font sizes (10-11), tighter gaps (4-8), less padding (10-15), and smaller images (max height 200). For multi-page documents (resumes, reports), pack content densely to avoid large blank areas at the bottom of pages. Use small margins (marginTop: 30, marginBottom: 20), tight spacing (gap: 4-6), and compact font sizes (9-11 for body text) so pages are well-filled. It is better to fit more content on fewer pages than to spread thin content across many pages.",
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist.",
],
},
);
export type ReactPdfSchema = typeof schema;
export type ReactPdfSpec<TCatalog> = typeof schema extends {
createCatalog: (catalog: TCatalog) => { _specType: infer S };
}
? S
: never;
-20
View File
@@ -1,20 +0,0 @@
// Server-safe entry point: schema and catalog definitions only.
// Does not import React or @react-pdf/renderer.
export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
export {
standardComponentDefinitions,
type StandardComponentDefinitions,
type StandardComponentProps,
} from "./catalog";
export type { Spec } from "@json-render/core";
export type {
SetState,
StateModel,
ComponentContext,
ComponentFn,
Components,
} from "./catalog-types";
-9
View File
@@ -1,9 +0,0 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-10
View File
@@ -1,10 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts", "src/server.ts", "src/catalog.ts", "src/render.tsx"],
format: ["cjs", "esm"],
dts: true,
sourcemap: true,
clean: true,
external: ["react", "@json-render/core", "@react-pdf/renderer", "zod"],
});
+48 -1820
View File
File diff suppressed because it is too large Load Diff