
Multi Surface Render
- 58 installs
- 213 repo stars
- Updated August 4, 2026
- yonatangross/orchestkit
Helps with ai & agent building tasks.
About
multi-surface-render is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- multi-surface-render
- AI & Agent Building
- AI-coding skill
Multi Surface Render by the numbers
- 58 all-time installs (skills.sh)
- Ranked #6,589 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/yonatangross/orchestkit --skill multi-surface-renderAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 58 |
|---|---|
| repo stars | ★ 213 |
| Last updated | August 4, 2026 |
| Repository | yonatangross/orchestkit ↗ |
What it does
Helps with ai & agent building tasks.
Files
Multi-Surface Rendering with json-render
Define once, render everywhere. A single json-render catalog and spec can produce React web UIs, PDF reports, HTML emails, Remotion demo videos, and OG images — each surface gets its own registry that maps catalog types to platform-native components.
Quick Reference
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| Target Selection | 1 | HIGH | Choosing which renderer for your use case |
| React Renderer | 1 | MEDIUM | Web apps, SPAs, dashboards |
| PDF & Email Renderer | 1 | HIGH | Reports, documents, notifications |
| Video & Image Renderer | 1 | MEDIUM | Demo videos, OG images, social cards |
| Registry Mapping | 1 | HIGH | Platform-specific component implementations |
Total: 5 rules across 5 categories
How Multi-Surface Rendering Works
1. One catalog — Zod-typed component definitions shared across all surfaces 2. One spec — flat-tree JSON/YAML describing the UI structure 3. Many registries — each surface maps catalog types to its own component implementations 4. Many renderers — each package renders the spec using its registry
The catalog is the contract. The spec is the data. The registry is the platform-specific implementation.
Quick Start — Same Catalog, Different Renderers
Shared Catalog (used by all surfaces)
import { defineCatalog } from '@json-render/core'
import { schema } from '@json-render/react/schema'
import { z } from 'zod'
export const catalog = defineCatalog(schema, {
components: {
Heading: {
props: z.object({
text: z.string(),
level: z.enum(['h1', 'h2', 'h3']),
}),
children: false,
},
Paragraph: {
props: z.object({ text: z.string() }),
children: false,
},
StatCard: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
}),
children: false,
},
},
})Render to Web (React)
import { Renderer } from '@json-render/react'
import { webRegistry } from './registries/web'
// webRegistry comes from `defineRegistry(catalog, { components })`.
// RendererProps is { spec, registry, loading?, fallback? } — no catalog prop.
export const Dashboard = ({ spec }) => (
<Renderer spec={spec} registry={webRegistry} />
)Render to PDF
import { renderToBuffer, renderToFile } from '@json-render/react-pdf'
import { pdfRegistry } from './registries/pdf'
// Buffer for HTTP response — options are { registry, includeStandard?, state? }
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })
// Direct file output — renderToFile(spec, filePath, options?)
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })Render to Email
import { renderToHtml } from '@json-render/react-email'
import { emailRegistry } from './registries/email'
const html = await renderToHtml(spec, { registry: emailRegistry })
await sendEmail({ to: user.email, subject: 'Weekly Report', html })Render to OG Image (Satori)
import { renderToSvg, renderToPng } from '@json-render/image'
import { imageRegistry } from './registries/image'
const png = await renderToPng(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})Render to Video (Remotion)
import { JsonRenderComposition } from '@json-render/remotion'
import { catalog } from './catalog'
import { remotionRegistry } from './registries/remotion'
export const DemoVideo = () => (
<JsonRenderComposition
spec={spec}
catalog={catalog}
registry={remotionRegistry}
fps={30}
durationInFrames={150}
/>
)Render to Terminal (Ink, 0.15+)
import { render } from 'ink'
import { InkRenderer } from '@json-render/ink'
import { catalog } from './catalog'
import { inkRegistry } from './registries/ink'
render(<InkRenderer spec={spec} catalog={catalog} registry={inkRegistry} />)Useful for /ork:* CLI dashboards and streaming agent chat interfaces — ships 20+ Ink-native components (Box, Text, Spinner, Table, Markdown, Progress, etc.).
Render to Next.js App (0.16+)
import { generateNextApp } from '@json-render/next'
await generateNextApp(spec, {
catalog,
registry: webRegistry,
outDir: './out',
// generates routes, layouts, SSR handlers, and metadata
})Output is a full Next.js App Router project — specs describe route trees, not just components.
Decision Matrix — When to Use Each Target
| Target | Package | When to Use | Output |
|---|---|---|---|
| React | @json-render/react | Web apps, SPAs | JSX |
| Next.js | @json-render/next (0.16+) | Full apps: routes, layouts, SSR, metadata | Next.js app |
| Vue | @json-render/vue | Vue projects | Vue components |
| Svelte | @json-render/svelte | Svelte projects | Svelte components |
| Svelte+shadcn | @json-render/shadcn-svelte (0.16+) | 36-component Svelte 5 catalog | Svelte + Tailwind |
| React Native | @json-render/react-native | Mobile apps (25+ components) | Native views |
| Terminal | @json-render/ink (0.15+) | CLI UIs, TUIs, streaming chat | Ink (terminal) |
@json-render/react-pdf | Reports, documents | PDF buffer/file | |
@json-render/react-email | Notifications, digests | HTML string | |
| Remotion | @json-render/remotion | Demo videos, marketing | MP4/WebM |
| Image | @json-render/image | OG images, social cards | SVG/PNG (Satori) |
| YAML | @json-render/yaml (0.14+) | Token optimization, streaming parser | YAML string |
| MCP | @json-render/mcp | Claude/Cursor/ChatGPT conversations | Sandboxed iframe |
| 3D | @json-render/react-three-fiber | 3D scenes (20 components, incl. GaussianSplat in 0.17) | Three.js canvas |
| Codegen | @json-render/codegen | Source code from specs | TypeScript/JSX |
Load rules/target-selection.md for detailed selection criteria and trade-offs.
PDF Renderer — Reports and Documents
The @json-render/react-pdf package renders specs to PDF using react-pdf under the hood. Three output modes: buffer, file, and stream.
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'
// In-memory buffer (for HTTP responses, S3 upload)
// options are { registry, includeStandard?, state? } — no catalog field
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
res.send(buffer)
// Direct file write — renderToFile(spec, filePath, options?)
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })
// Streaming (for large documents)
const stream = await renderToStream(spec, { registry: pdfRegistry })
stream.pipe(res)Load rules/pdf-email-renderer.md for PDF registry patterns and email rendering.
Image Renderer — OG Images and Social Cards
The @json-render/image package uses Satori to convert specs to SVG, then optionally to PNG. Designed for server-side generation of social media images.
import { renderToSvg, renderToPng } from '@json-render/image'
// SVG output (smaller, scalable)
const svg = await renderToSvg(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})
// PNG output (universal compatibility)
const png = await renderToPng(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})Load rules/video-image-renderer.md for Satori constraints and Remotion composition patterns.
Registry Mapping — Same Catalog, Platform-Specific Components
Each surface needs its own registry. The registry maps catalog types to platform-specific component implementations while the catalog and spec stay identical.
// Web registry — uses HTML elements
const webRegistry = {
Heading: ({ text, level }) => {
const Tag = level // h1, h2, h3
return <Tag className="font-bold">{text}</Tag>
},
StatCard: ({ label, value, trend }) => (
<div className="rounded border p-4">
<span className="text-sm text-gray-500">{label}</span>
<strong className="text-2xl">{value}</strong>
</div>
),
}
// PDF registry — uses react-pdf primitives
import { Text, View } from '@react-pdf/renderer'
const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={{ border: '1pt solid #ccc', padding: 8 }}>
<Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
</View>
),
}Load rules/registry-mapping.md for registry creation patterns and type safety.
Rule Details
Target Selection
Decision criteria for choosing the right renderer target.
| Rule | File | Key Pattern |
|---|---|---|
| Target Selection | rules/target-selection.md | Use case mapping, output format constraints |
React Renderer
Web rendering with the <Renderer> component.
| Rule | File | Key Pattern |
|---|---|---|
| React Renderer | rules/react-renderer.md | <Renderer> component, streaming, error boundaries |
PDF & Email Renderer
Server-side rendering to PDF buffers/files and HTML email strings.
| Rule | File | Key Pattern |
|---|---|---|
| PDF & Email | rules/pdf-email-renderer.md | renderToBuffer, renderToFile, renderToHtml |
Video & Image Renderer
Remotion compositions and Satori image generation.
| Rule | File | Key Pattern |
|---|---|---|
| Video & Image | rules/video-image-renderer.md | JsonRenderComposition, renderToPng, renderToSvg |
Registry Mapping
Creating platform-specific registries for a shared catalog.
| Rule | File | Key Pattern |
|---|---|---|
| Registry Mapping | rules/registry-mapping.md | Per-platform registries, type-safe mapping |
Key Decisions
| Decision | Recommendation |
|---|---|
| PDF library | Use @json-render/react-pdf (react-pdf), not Puppeteer screenshots |
| Email rendering | Use @json-render/react-email (react-email), not MJML or custom HTML |
| OG images | Use @json-render/image (Satori), not Puppeteer or canvas |
| Video | Use @json-render/remotion (Remotion), not FFmpeg scripts |
| Registry per platform | Always separate registries; never one registry for all surfaces |
| Catalog sharing | One catalog definition shared via import across all registries |
Common Mistakes
1. Building separate component trees for each surface — defeats the purpose; share the catalog and spec 2. Using Puppeteer to screenshot React for PDF generation — slow, fragile; use native react-pdf rendering 3. One giant registry covering all platforms — impossible since PDF uses <View>/<Text>, web uses <div>/<span> 4. Forgetting Satori limitations — no CSS grid, limited flexbox; design image registries with these constraints 5. Duplicating catalog definitions per surface — one catalog, many registries; the catalog is the contract
Related Skills
ork:json-render-catalog— Catalog definition patterns with Zod, shadcn componentsork:demo-producer— Video production pipeline using Remotionork:presentation-builder— Slide deck generationork:mcp-visual-output— Rendering specs in Claude/Cursor via MCP
Renderer API Reference
@json-render/react
<Renderer> Component
import { Renderer } from '@json-render/react'
<Renderer
spec={spec} // JsonRenderSpec — flat-tree JSON/YAML
registry={registry} // DefineRegistryResult from defineRegistry()
loading={isLoading} // Optional: boolean, shows fallback while true
fallback={<Loading />} // Optional: shown during streaming or loading
/>useStreamingSpec Hook
import { useStreamingSpec } from '@json-render/react'
const spec = useStreamingSpec(stream) // ReadableStream of JSON Patch ops---
@json-render/react-pdf
renderToBuffer(spec, options): Promise<Buffer>
Renders spec to an in-memory PDF buffer.
import { renderToBuffer } from '@json-render/react-pdf'
const buffer = await renderToBuffer(spec, {
registry: pdfRegistry,
pageSize: 'A4', // Optional: 'A4' | 'LETTER' | { width, height }
orientation: 'portrait', // Optional: 'portrait' | 'landscape'
margins: { top: 40, bottom: 40, left: 40, right: 40 }, // Optional
})renderToFile(spec, path, options): Promise<void>
Renders spec directly to a PDF file on disk.
import { renderToFile } from '@json-render/react-pdf'
await renderToFile(spec, './output/report.pdf', {
registry: pdfRegistry,
pageSize: 'A4',
})renderToStream(spec, options): Promise<ReadableStream>
Renders spec to a readable stream for piping to HTTP responses.
import { renderToStream } from '@json-render/react-pdf'
const stream = await renderToStream(spec, { registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', 'attachment; filename="report.pdf"')
stream.pipe(res)---
@json-render/react-email
renderToHtml(spec, options): Promise<string>
Renders spec to an HTML string optimized for email clients.
import { renderToHtml } from '@json-render/react-email'
const html = await renderToHtml(spec, {
registry: emailRegistry,
preview: 'Your weekly report is ready', // Optional: email preview text
theme: 'light', // Optional: 'light' | 'dark'
})renderToPlainText(spec, options): Promise<string>
Renders spec to plain text (for text/plain multipart emails).
import { renderToPlainText } from '@json-render/react-email'
const text = await renderToPlainText(spec, { registry: emailRegistry })---
@json-render/image
renderToSvg(spec, options): Promise<string>
Renders spec to an SVG string using Satori.
import { renderToSvg } from '@json-render/image'
const svg = await renderToSvg(spec, {
registry: imageRegistry,
width: 1200, // Required
height: 630, // Required
fonts: [{ // Optional: custom fonts
name: 'Inter',
data: fontBuffer,
weight: 400,
style: 'normal',
}],
})renderToPng(spec, options): Promise<Buffer>
Renders spec to a PNG buffer (SVG -> PNG via Resvg).
import { renderToPng } from '@json-render/image'
const png = await renderToPng(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})---
@json-render/remotion
<JsonRenderComposition> Component
import { JsonRenderComposition } from '@json-render/remotion'
<JsonRenderComposition
spec={spec} // JsonRenderSpec
catalog={catalog} // Catalog from defineCatalog()
registry={remotionRegistry} // Registry with Remotion animations
fps={30} // Frames per second
durationInFrames={150} // Total duration
width={1920} // Video width
height={1080} // Video height
/>Remotion Registry with Animations
import { useCurrentFrame, interpolate, spring, useVideoConfig } from 'remotion'
const remotionRegistry = {
Heading: ({ text, level }) => {
const frame = useCurrentFrame()
const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' })
return <h1 style={{ opacity, fontSize: level === 'h1' ? 48 : 36 }}>{text}</h1>
},
}---
Common Options (all renderers)
| Option | Type | Description |
|---|---|---|
catalog | Catalog | Required. Zod-typed component definitions |
registry | CatalogComponents | Required. Platform-specific component map |
spec | JsonRenderSpec | Required. Flat-tree JSON/YAML spec |
onError | (err: Error) => void | Optional. Error handler |
Target Comparison Matrix
All Targets — Capabilities Overview
| Target | Package | Output | Server-Side | Streaming | File Output | Sizing |
|---|---|---|---|---|---|---|
| React | @json-render/react | JSX | No (client) | Yes (progressive) | No | Responsive |
| Vue | @json-render/vue | Vue components | SSR supported | Yes | No | Responsive |
| Svelte | @json-render/svelte | Svelte components | SSR supported | Yes | No | Responsive |
| React Native | @json-render/react-native | Native views | No | Yes | No | Flex-based |
@json-render/react-pdf | Buffer/File/Stream | Yes | Via stream | Yes | Fixed (A4, Letter) | |
@json-render/react-email | HTML string | Yes | No | No | 600px max-width | |
| Remotion | @json-render/remotion | MP4/WebM | Yes (render) | No | Yes | Fixed (px) |
| Image | @json-render/image | SVG/PNG | Yes | No | Yes (buffer) | Fixed (px) |
| YAML | @json-render/yaml | YAML string | Yes | No | No | N/A |
| MCP | @json-render/mcp | Sandboxed iframe | Yes | Yes | No | Constrained |
| 3D | @json-render/react-three-fiber | Three.js canvas | No | No | No | Canvas-based |
| Codegen | @json-render/codegen | TypeScript/JSX | Yes | No | Yes | N/A |
Registry Component Primitives by Target
| Target | Base Elements | Styling | Layout |
|---|---|---|---|
| React | div, span, h1-h6, p, img | className, CSS modules, Tailwind | Flexbox, Grid, any CSS |
View, Text, Image (react-pdf) | StyleSheet.create() | Flexbox only | |
Section, Text, Heading, Button (react-email) | Inline styles | Table-based (email compat) | |
| Image | div, span, img (Satori subset) | Inline styles only | Flexbox only (limited) |
| Remotion | div, span + Remotion primitives | Inline styles, CSS | Flexbox, absolute positioning |
| React Native | View, Text, Image (RN) | StyleSheet.create() | Flexbox only |
Limitations by Target
PDF (@json-render/react-pdf)
- No CSS grid — flexbox only
- No className — use
StyleSheet.create() - No
<div>/<span>— must use<View>/<Text> - Font embedding required for custom fonts
- No interactive elements (buttons, links are display-only)
Email (@json-render/react-email)
- 600px max-width (email client constraint)
- Table-based layout for Outlook compatibility
- Limited CSS support (no flexbox in Outlook, no grid)
- Inline styles only for maximum compatibility
- No JavaScript interactivity
Image (@json-render/image / Satori)
- No CSS grid
- Limited flexbox (no
flex-wrap, limitedalign-items) - No
box-shadow - No CSS animations or transitions
- No external stylesheets — inline styles only
position: absolutehas limited nesting support- Custom fonts must be loaded as ArrayBuffer
Remotion (@json-render/remotion)
- No user interaction (pre-rendered video)
- Rendering is CPU-intensive — use cloud rendering for production
- Must specify exact
durationInFramesandfps - All animations must be frame-based (
useCurrentFrame)
React Native (@json-render/react-native)
- No
div/span— useView/Text - No CSS grid — flexbox only
- No className — use
StyleSheet.create() - Platform-specific behavior (iOS vs Android)
Performance Characteristics
| Target | Render Time | Memory | CPU |
|---|---|---|---|
| React | <50ms (client) | Low | Low |
| PDF (buffer) | 200-500ms | Medium | Medium |
| PDF (stream) | 100-300ms start | Low | Medium |
| 50-100ms | Low | Low | |
| Image (SVG) | 100-200ms | Low | Low |
| Image (PNG) | 200-400ms | Medium | Medium |
| Remotion | 10-60s (full render) | High | High |
| Codegen | 50-100ms | Low | Low |
When to Use Multiple Targets
Common multi-target combinations:
| Use Case | Targets | Example |
|---|---|---|
| Dashboard + PDF export | React + PDF | Web dashboard with "Download PDF" button |
| Dashboard + email digest | React + Email | Web view + weekly email summary |
| Blog + social sharing | React + Image | Blog post + OG image preview |
| Product page + demo | React + Remotion | Landing page + demo video |
| Full marketing suite | React + PDF + Email + Image + Remotion | All surfaces from one spec |
<!-- SYNCED from vercel-labs/json-render (skills/react-email/SKILL.md) --> <!-- Hash: 8b376b37da07dd68944e72e0e322a272571f39271e5b08b6efd437db201470c9 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/react-email
React Email renderer that converts JSON specs into HTML or plain-text email output.
Quick Start
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
const spec = {
root: "html-1",
elements: {
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
props: { style: { backgroundColor: "#f6f9fc" } },
children: ["container-1"],
},
"container-1": {
type: "Container",
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
},
};
const html = await renderToHtml(spec);Spec Structure (Element Tree)
Same flat element tree as @json-render/react: root key plus elements map. Root must be Html; children of Html should be Head and Body. Use Container (e.g. max-width 600px) inside Body for client-safe layout.
Creating a Catalog and Registry
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
import { standardComponentDefinitions } from "@json-render/react-email/catalog";
import { Container, Heading, Text } from "@react-email/components";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Alert: {
props: z.object({
message: z.string(),
variant: z.enum(["info", "success", "warning"]).nullable(),
}),
slots: [],
description: "A highlighted message block",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Alert: ({ props }) => (
<Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
<Text style={{ margin: 0 }}>{props.message}</Text>
</Container>
),
},
});
const html = await renderToHtml(spec, { registry });Server-Side Render APIs
| Function | Purpose |
|---|---|
renderToHtml(spec, options?) | Render spec to HTML email string |
renderToPlainText(spec, options?) | Render spec to plain-text email string |
RenderOptions: registry, includeStandard (default true), state (for $state / $cond).
Visibility and State
Supports visible conditions, $state, $cond, repeat (repeat.statePath), and the same expression syntax as @json-render/react. Use state in RenderOptions when rendering server-side so expressions resolve.
Server-Safe Import
Import schema and catalog without React or @react-email/components:
import { schema, standardComponentDefinitions } from "@json-render/react-email/server";Key Exports
| Export | Purpose |
|---|---|
defineRegistry | Create type-safe component registry from catalog |
Renderer | Render spec in browser (e.g. preview); use with JSONUIProvider for state/actions |
createRenderer | Standalone renderer component with state/actions/validation |
renderToHtml | Server: spec to HTML string |
renderToPlainText | Server: spec to plain-text string |
schema | Email element schema |
standardComponents | Pre-built component implementations |
standardComponentDefinitions | Catalog definitions (Zod props) |
Sub-path Exports
| Path | Purpose |
|---|---|
@json-render/react-email | Full package |
@json-render/react-email/server | Schema and catalog only (no React) |
@json-render/react-email/catalog | Standard component definitions and types |
@json-render/react-email/render | Render functions only |
Standard Components
All components accept a style prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS.
Document structure
| Component | Description |
|---|---|
Html | Root wrapper (lang, dir). Children: Head, Body. |
Head | Email head section. |
Body | Body wrapper; use style for background. |
Layout
| Component | Description |
|---|---|
Container | Constrain width (e.g. max-width 600px). |
Section | Group content; table-based for compatibility. |
Row | Horizontal row. |
Column | Column in a Row; set width via style. |
Content
| Component | Description |
|---|---|
Heading | Heading text (as: h1–h6). |
Text | Body text. |
Link | Hyperlink (text, href). |
Button | CTA link styled as button (text, href). |
Image | Image from URL (src, alt, width, height). |
Hr | Horizontal rule. |
Utility
| Component | Description |
|---|---|
Preview | Inbox preview text (inside Html). |
Markdown | Markdown content as email-safe HTML. |
Email Best Practices
- Keep width constrained (e.g. Container max-width 600px).
- Use inline styles or React Email's style props; many clients strip
<style>blocks. - Prefer table-based layout (Section, Row, Column) for broad client support.
- Use absolute URLs for images; many clients block relative or cid: references in some contexts.
- Test in multiple clients (Gmail, Outlook, Apple Mail); use a preview tool or Litmus-like service when possible.
<!-- SYNCED from vercel-labs/json-render (skills/image/SKILL.md) --> <!-- Hash: fc6469e1592a86d4d92058b81704023394ab8cbdd9f422d541ad3a752f2a0e42 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/image
Image renderer that converts JSON specs into SVG and PNG images using Satori.
Quick Start
import { renderToPng } from "@json-render/image/render";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
const png = await renderToPng(spec, {
fonts: [{ name: "Inter", data: fontData, weight: 400, style: "normal" }],
});Using Standard Components
import { defineCatalog } from "@json-render/core";
import { schema, standardComponentDefinitions } from "@json-render/image";
export const imageCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});Adding Custom Components
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",
},
},
});Standard Components
| Component | Category | Description |
|---|---|---|
Frame | Root | Root container. Defines width, height, background. Must be root. |
Box | Layout | Container with padding, margin, border, absolute positioning |
Row | Layout | Horizontal flex layout |
Column | Layout | Vertical flex layout |
Heading | Content | h1-h4 heading text |
Text | Content | Body text with full styling |
Image | Content | Image from URL |
Divider | Decorative | Horizontal line separator |
Spacer | Decorative | Empty vertical space |
Key Exports
| Export | Purpose |
|---|---|
renderToSvg | Render spec to SVG string |
renderToPng | Render spec to PNG buffer (requires @resvg/resvg-js) |
schema | Image element schema |
standardComponents | Pre-built component registry |
standardComponentDefinitions | Catalog definitions for AI prompts |
Sub-path Exports
| Export | Description |
|---|---|
@json-render/image | Full package: schema, components, render functions |
@json-render/image/server | Schema and catalog definitions only (no React/Satori) |
@json-render/image/catalog | Standard component definitions and types |
@json-render/image/render | Render functions only |
<!-- SYNCED from vercel-labs/json-render (skills/react-pdf/SKILL.md) --> <!-- Hash: 42d7d378733e0c1e6ff10ba788dc77c32b36b2f28841f1d6aa671e44ed9ad0be --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/react-pdf
React PDF renderer that generates PDF documents from JSON specs using @react-pdf/renderer.
Installation
npm install @json-render/core @json-render/react-pdfQuick Start
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);Render APIs
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
// In-memory buffer
const buffer = await renderToBuffer(spec);
// Readable stream (pipe to HTTP response)
const stream = await renderToStream(spec);
stream.pipe(res);
// Direct to file
await renderToFile(spec, "./output.pdf");All render functions accept an optional second argument: { registry?, state?, handlers? }.
Standard Components
| Component | Description |
|---|---|
Document | Top-level PDF wrapper (must be root) |
Page | Page with size (A4, LETTER), orientation, margins |
View | Generic container (padding, margin, background, border) |
Row, Column | Flex layout with gap, align, justify |
Heading | h1-h4 heading text |
Text | Body text (fontSize, color, weight, alignment) |
Image | Image from URL or base64 |
Link | Hyperlink with text and href |
Table | Data table with typed columns and rows |
List | Ordered or unordered list |
Divider | Horizontal line separator |
Spacer | Empty vertical space |
PageNumber | Current page number and total pages |
Custom Catalog
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 }}>
<Text>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });External Store (Controlled Mode)
Pass a StateStore for full control over state:
import { createStateStore } from "@json-render/react-pdf";
const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);Server-Safe Import
Import schema and catalog without pulling in React:
import { schema, standardComponentDefinitions } from "@json-render/react-pdf/server";<!-- SYNCED from vercel-labs/json-render (skills/remotion/SKILL.md) --> <!-- Hash: 59d63ac06729795f0981fe0cfadf6c1c34454be0329a34f2d36c9be2391fbbe9 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/remotion
Remotion renderer that converts JSON timeline specs into video compositions.
Quick Start
import { Player } from "@remotion/player";
import { Renderer, type TimelineSpec } from "@json-render/remotion";
function VideoPlayer({ spec }: { spec: TimelineSpec }) {
return (
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
controls
/>
);
}Using Standard Components
import { defineCatalog } from "@json-render/core";
import {
schema,
standardComponentDefinitions,
standardTransitionDefinitions,
standardEffectDefinitions,
} from "@json-render/remotion";
export const videoCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});Adding Custom Components
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
MyCustomClip: {
props: z.object({ text: z.string() }),
type: "scene",
defaultDuration: 90,
description: "My custom video clip",
},
},
});
// Pass custom component to Renderer
<Player
component={Renderer}
inputProps={{
spec,
components: { MyCustomClip: MyCustomComponent },
}}
/>Timeline Spec Structure
{
"composition": { "id": "video", "fps": 30, "width": 1920, "height": 1080, "durationInFrames": 300 },
"tracks": [{ "id": "main", "name": "Main", "type": "video", "enabled": true }],
"clips": [
{ "id": "clip-1", "trackId": "main", "component": "TitleCard", "props": { "title": "Hello" }, "from": 0, "durationInFrames": 90 }
],
"audio": { "tracks": [] }
}Standard Components
| Component | Type | Description |
|---|---|---|
TitleCard | scene | Full-screen title with subtitle |
TypingText | scene | Terminal-style typing animation |
ImageSlide | image | Full-screen image display |
SplitScreen | scene | Two-panel comparison |
QuoteCard | scene | Quote with attribution |
StatCard | scene | Animated statistic display |
TextOverlay | overlay | Text overlay |
LowerThird | overlay | Name/title overlay |
Key Exports
| Export | Purpose |
|---|---|
Renderer | Render spec to Remotion composition |
schema | Timeline schema |
standardComponents | Pre-built component registry |
standardComponentDefinitions | Catalog definitions |
useTransition | Transition animation hook |
ClipWrapper | Wrap clips with transitions |
Rule Categories
1. Target Selection (target) — HIGH — 1 rule
Decision criteria for selecting the right renderer target based on output format, platform constraints, and use case.
target-selection.md— Use case mapping, output format constraints, package selection
2. React Renderer (react) — MEDIUM — 1 rule
Web rendering patterns using the <Renderer> component with progressive streaming and error boundaries.
react-renderer.md— Renderer component, streaming, error boundaries, hydration
3. PDF & Email Renderer (pdf-email) — HIGH — 2 targets in 1 rule
Server-side rendering to PDF (renderToBuffer, renderToFile, renderToStream) and HTML email (renderToHtml).
pdf-email-renderer.md— react-pdf output modes, react-email HTML generation, registry patterns
4. Video & Image Renderer (video-image) — MEDIUM — 2 targets in 1 rule
Remotion compositions for video and Satori-based image generation for OG images and social cards.
video-image-renderer.md— JsonRenderComposition, renderToPng, renderToSvg, Satori constraints
5. Registry Mapping (registry) — HIGH — 1 rule
Creating platform-specific registries that map a shared catalog to surface-native component implementations.
registry-mapping.md— Per-platform registries, type-safe mapping, shared catalog pattern
[Rule Name]
[Brief description — 1-2 sentences.]
Incorrect:
// Bad patternCorrect:
// Good patternKey rules:
- [Rule 1]
- [Rule 2]
- [Rule 3]
Reference: [link]
PDF & Email Renderer
@json-render/react-pdf renders specs to PDF using react-pdf primitives (View, Text, Image). @json-render/react-email renders specs to HTML email strings using react-email components. Both validate against the same catalog.
Incorrect — using Puppeteer to screenshot React for PDF:
// WRONG: Launches a browser, takes a screenshot, converts to PDF
import puppeteer from 'puppeteer'
async function generatePdf(spec) {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(renderToString(<Dashboard spec={spec} />))
const pdf = await page.pdf({ format: 'A4' })
await browser.close()
return pdf // slow, ~2s startup, CSS rendering differences, no catalog validation
}Correct — native PDF rendering via react-pdf:
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'
import { catalog } from './catalog'
import { pdfRegistry } from './registries/pdf'
// Buffer — for HTTP responses, S3 upload, attachments
const buffer = await renderToBuffer(spec, { registry: pdfRegistry })
// File — direct disk write
await renderToFile(spec, './output/report.pdf', { registry: pdfRegistry })
// Stream — for large documents, pipe to HTTP response
const stream = await renderToStream(spec, { registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
stream.pipe(res)Incorrect — manual HTML string for email:
// WRONG: Manual HTML, no validation, rendering inconsistencies
const html = `<table><tr><td>${data.title}</td></tr></table>`Correct — react-email rendering:
import { renderToHtml } from '@json-render/react-email'
import { catalog } from './catalog'
import { emailRegistry } from './registries/email'
const html = await renderToHtml(spec, { registry: emailRegistry })
await transporter.sendMail({ to: user.email, subject: 'Report', html })Key rules:
- Use
renderToBufferfor in-memory PDF (HTTP responses, email attachments, cloud storage upload) - Use
renderToFilefor disk output (batch report generation, CI artifacts) - Use
renderToStreamfor large documents to avoid buffering the entire PDF in memory - PDF registry components must use react-pdf primitives (
View,Text,Image) — not HTML elements - Email registry components must use react-email primitives (
Section,Text,Heading) — not arbitrary HTML - Both renderers validate specs against the catalog — invalid types or props throw at render time
PDF Registry Pattern
import { View, Text, StyleSheet } from '@react-pdf/renderer'
const styles = StyleSheet.create({
heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
card: { border: '1pt solid #e5e7eb', padding: 12, borderRadius: 4 },
})
export const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ ...styles.heading, fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={styles.card}>
<Text style={{ fontSize: 10, color: '#6b7280' }}>{label}</Text>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
</View>
),
}React Renderer
The <Renderer> component from @json-render/react validates specs against the catalog at runtime and renders each element using the registry. Never parse specs manually.
Incorrect — parsing the spec manually:
// WRONG: Manual parsing, no validation, no streaming
function Dashboard({ spec }) {
return (
<div>
{Object.entries(spec.elements).map(([id, el]) => {
const Component = components[el.type] // no catalog validation
return <Component key={id} {...el.props} />
})}
</div>
)
}Correct — using the Renderer component:
import { Renderer, defineRegistry } from '@json-render/react'
import { catalog } from './catalog'
import { webComponents } from './registries/web'
const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })
function Dashboard({ spec }) {
return (
<Renderer
spec={spec}
registry={webRegistry}
fallback={<LoadingSkeleton />}
/>
)
}Key rules:
- Build the registry with
defineRegistry(catalog, { components })— this binds catalog validation to the registry;<Renderer>receives onlyspecandregistry(nocatalogprop in 0.19) RendererPropsis{ spec, registry, loading?, fallback? }— nocatalog,components,directives, oronErrorat top level- Use
fallbackprop for loading states during progressive streaming - Wrap in a React error boundary for graceful degradation
- For streaming specs (AI generating in real-time), the Renderer updates progressively as elements arrive
- The registry maps catalog types to React components — keep it separate from the catalog definition
Progressive Streaming Pattern
import { Renderer, defineRegistry, useStreamingSpec } from '@json-render/react'
import { catalog } from './catalog'
import { webComponents } from './registries/web'
const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })
function StreamingDashboard({ specStream }) {
const spec = useStreamingSpec(specStream) // updates as patches arrive
return (
<Renderer
spec={spec}
registry={webRegistry}
fallback={<Skeleton />}
/>
)
}Elements render as soon as their props are complete — the user sees the UI building in real-time.
Registry Mapping
A registry maps each catalog type to a platform-specific component implementation. The catalog (Zod schemas) and spec (flat-tree data) stay identical across surfaces. Only the registry changes.
Incorrect — one giant registry trying to cover all platforms:
// WRONG: Impossible — PDF needs View/Text, web needs div/span
const universalRegistry = {
Heading: ({ text, level, platform }) => {
if (platform === 'pdf') return <Text style={...}>{text}</Text>
if (platform === 'email') return <Heading as={level}>{text}</Heading>
return <h1>{text}</h1> // web fallback
},
}Correct — separate registries per platform, same catalog:
import { catalog } from './catalog' // SHARED — one definition
// Web registry
export const webRegistry = {
Heading: ({ text, level }) => {
const Tag = level
return <Tag className="font-bold tracking-tight">{text}</Tag>
},
StatCard: ({ label, value, trend }) => (
<div className="rounded-lg border p-4 shadow-sm">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{trend && <TrendIcon direction={trend} />}
</div>
),
}
// PDF registry
import { View, Text } from '@react-pdf/renderer'
export const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ fontSize: level === 'h1' ? 24 : 18, fontWeight: 'bold' }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={{ border: '1pt solid #ccc', padding: 8 }}>
<Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
<Text style={{ fontSize: 18 }}>{value}</Text>
</View>
),
}
// Email registry
import { Section, Text as EmailText, Heading as EmailHeading } from '@react-email/components'
export const emailRegistry = {
Heading: ({ text, level }) => (
<EmailHeading as={level}>{text}</EmailHeading>
),
StatCard: ({ label, value }) => (
<Section style={{ border: '1px solid #e5e7eb', padding: '12px' }}>
<EmailText style={{ fontSize: '12px', color: '#6b7280' }}>{label}</EmailText>
<EmailText style={{ fontSize: '20px', fontWeight: 'bold' }}>{value}</EmailText>
</Section>
),
}Key rules:
- One catalog, many registries — the catalog defines WHAT can be rendered, registries define HOW
- Every catalog type must have an entry in each registry — missing entries throw at render time
- Registry components receive the same props defined in the catalog Zod schema
- Never add platform-specific props to the catalog — the catalog is platform-agnostic
- Organize registries in
./registries/web.ts,./registries/pdf.ts,./registries/email.ts - Use
CatalogComponents<typeof catalog>type to ensure registries match the catalog
Type-Safe Registry Pattern
import type { CatalogComponents } from '@json-render/react'
import type { catalog } from './catalog'
// TypeScript ensures every catalog type is implemented
export const webRegistry: CatalogComponents<typeof catalog> = {
Heading: ({ text, level }) => { /* ... */ },
StatCard: ({ label, value, trend }) => { /* ... */ },
// Missing type → TypeScript error
}File Organization
src/
catalog.ts # Shared catalog (Zod schemas)
registries/
web.ts # React/HTML components
pdf.ts # react-pdf View/Text components
email.ts # react-email Section/Text components
image.ts # Satori-compatible inline-style components
remotion.ts # Remotion-animated componentsTarget Selection
Choose the renderer target based on what the output is, not what framework you use. Each target maps to a specific @json-render/* package with its own rendering pipeline.
Incorrect — building separate templates for each surface:
// WRONG: Separate template systems, no shared catalog
const webDashboard = buildReactComponents(data)
const pdfReport = buildPdfWithPuppeteer(data) // puppeteer screenshot
const emailDigest = buildMjmlEmail(data) // separate MJML templates
const ogImage = buildCanvasImage(data) // manual canvas drawingCorrect — one catalog, one spec, multiple registries:
import { catalog } from './catalog'
import { defineRegistry } from '@json-render/react'
// Same spec, different renderers
import { Renderer } from '@json-render/react' // web
import { renderToBuffer } from '@json-render/react-pdf' // pdf
import { renderToHtml } from '@json-render/react-email' // email
import { renderToPng } from '@json-render/image' // og image
const { registry: webRegistry } = defineRegistry(catalog, { components: webComponents })
// Each renderer uses the same catalog + spec, different registry
const webUi = <Renderer spec={spec} registry={webRegistry} />
const pdf = await renderToBuffer(spec, { registry: pdfRegistry })
const html = await renderToHtml(spec, { registry: emailRegistry })
const png = await renderToPng(spec, { registry: imageRegistry, width: 1200, height: 630 })Key rules:
- Match target to output format: PDF document =
react-pdf, HTML email =react-email, image =image - Never use Puppeteer/Playwright to screenshot a React page for PDF — use native
@json-render/react-pdf - Never build custom MJML/HTML templates when
@json-render/react-emailexists - If output is a file (PDF, PNG, MP4), use the server-side renderer — not the React
<Renderer>component - Multiple targets in one project is the normal case — that is the entire point of json-render
Selection Checklist
| Need | Target | Package |
|---|---|---|
| Interactive web UI | React | @json-render/react |
| Downloadable document | @json-render/react-pdf | |
| Transactional email | @json-render/react-email | |
| Social preview card | Image | @json-render/image |
| Marketing video | Video | @json-render/remotion |
| Mobile app screen | React Native | @json-render/react-native |
| AI conversation output | MCP | @json-render/mcp |
| Source code generation | Codegen | @json-render/codegen |
Video & Image Renderer
@json-render/remotion wraps specs into Remotion compositions for MP4/WebM video. @json-render/image uses Satori to render specs as SVG, then optionally converts to PNG for OG images and social cards.
Incorrect — manually creating Remotion timelines:
// WRONG: Manual timeline, no catalog validation, duplicated rendering logic
export const MyVideo = () => (
<Composition
id="demo"
component={() => (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
</div>
)}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
/>
)Correct — JsonRenderComposition from spec:
import { JsonRenderComposition } from '@json-render/remotion'
import { catalog } from './catalog'
import { remotionRegistry } from './registries/remotion'
export const DemoVideo = () => (
<JsonRenderComposition
spec={spec}
catalog={catalog}
registry={remotionRegistry}
fps={30}
durationInFrames={150}
width={1920}
height={1080}
/>
)Incorrect — using Puppeteer for OG images:
// WRONG: Launches browser, screenshots a page, saves as PNG
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setViewport({ width: 1200, height: 630 })
await page.setContent(`<div style="...">${title}</div>`)
const png = await page.screenshot({ type: 'png' })Correct — Satori-based image rendering:
import { renderToSvg, renderToPng } from '@json-render/image'
import { catalog } from './catalog'
import { imageRegistry } from './registries/image'
// SVG (smaller file size, scalable)
const svg = await renderToSvg(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})
// PNG (universal compatibility)
const png = await renderToPng(spec, {
registry: imageRegistry,
width: 1200,
height: 630,
})Key rules:
- Use
JsonRenderCompositionfor Remotion videos — it validates specs against the catalog - Use
renderToPngfor OG images (1200x630 standard) — Satori is server-side, no browser needed - Satori has CSS limitations: no CSS grid, limited flexbox, no
position: absolutenesting — design image registries accordingly - Image registries must use inline styles only — Satori does not support className or CSS files
- For Remotion, the registry can use Remotion animation primitives (
useCurrentFrame,interpolate,spring)
Satori CSS Constraints
| Supported | Not Supported |
|---|---|
| Flexbox (basic) | CSS Grid |
border, borderRadius | box-shadow |
padding, margin | position: absolute (limited) |
fontSize, fontWeight | External CSS, className |
color, backgroundColor | CSS animations |
width, height | Media queries |
Common OG Image Dimensions
| Platform | Width | Height | Ratio |
|---|---|---|---|
| Open Graph (general) | 1200 | 630 | 1.91:1 |
| Twitter card | 1200 | 628 | 1.91:1 |
| LinkedIn share | 1200 | 627 | 1.91:1 |
| Facebook share | 1200 | 630 | 1.91:1 |
{
"skill": "multi-surface-render",
"version": "1.0.0",
"testCases": [
{
"id": "basic-generate-pdf-report",
"rule": "",
"query": "Generate a PDF report from a dashboard spec using json-render.",
"expectedBehavior": [
"Recommends @json-render/react-pdf for PDF output",
"Shows renderToBuffer or renderToFile API usage",
"Creates a PDF-specific registry mapping catalog types to react-pdf primitives (View, Text)",
"Shares the same catalog definition used by web rendering",
"Does NOT suggest using Puppeteer to screenshot a React page"
]
},
{
"id": "basic-create-og-image",
"rule": "",
"query": "Create an OG image for a blog post using our json-render catalog.",
"expectedBehavior": [
"Recommends @json-render/image with Satori for OG image generation",
"Shows renderToPng with width 1200 and height 630 for standard OG dimensions",
"Creates an image-specific registry respecting Satori CSS limitations",
"Uses the shared catalog, not a separate image-specific catalog",
"Mentions Satori constraints: no CSS grid, limited flexbox"
]
},
{
"id": "basic-same-component-web-email",
"rule": "",
"query": "I need the same notification card to render on our web app and in email digests. How do I set this up with json-render?",
"expectedBehavior": [
"Defines a single catalog with the notification card type",
"Creates two registries: web (HTML elements) and email (react-email components)",
"Shows renderToHtml from @json-render/react-email for email output",
"Shows <Renderer> from @json-render/react for web output",
"Same spec works for both surfaces without modification"
]
},
{
"id": "basic-choose-renderer",
"rule": "",
"query": "How do I choose which json-render renderer to use for my project?",
"expectedBehavior": [
"References the decision matrix with all 12 targets",
"Asks about the output format needed (web, PDF, email, video, image)",
"Recommends specific packages based on use case",
"Does NOT recommend building a custom renderer when a package exists"
]
},
{
"id": "target-selection",
"rule": "target-selection",
"query": "We need our dashboard to work as a web app, a weekly PDF email attachment, and a social media preview image. Which json-render targets should we use?",
"expectedBehavior": [
"Identifies three targets: @json-render/react, @json-render/react-pdf, @json-render/image",
"Recommends a single shared catalog for all three surfaces",
"Creates three separate registries with platform-specific components",
"Does NOT suggest building separate template systems for each output",
"Mentions @json-render/react-email if the email body itself needs rendering"
]
},
{
"id": "registry-mapping",
"rule": "registry-mapping",
"query": "How do I create a PDF registry that maps my existing web catalog components to react-pdf primitives?",
"expectedBehavior": [
"Imports the existing catalog unchanged",
"Creates a new pdfRegistry object mapping each catalog type to react-pdf components",
"Uses View and Text from @react-pdf/renderer, not HTML div and span",
"Maintains the same prop interface so the spec works without changes",
"Does NOT modify the original catalog to accommodate PDF-specific concerns"
]
}
]
}