mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
023ca789b2 | ||
|
|
3f1e71e779 | ||
|
|
553c803422 |
@@ -15,7 +15,8 @@
|
||||
"@json-render/redux",
|
||||
"@json-render/jotai",
|
||||
"@json-render/vue",
|
||||
"@json-render/xstate"
|
||||
"@json-render/xstate",
|
||||
"@json-render/image"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -73,6 +73,41 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
|
||||
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||
- `AGENTS.md` (if workflow or conventions change)
|
||||
|
||||
## Releases
|
||||
|
||||
This monorepo uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
|
||||
|
||||
### Fixed version group
|
||||
|
||||
All public `@json-render/*` packages are in a **fixed** group (see `.changeset/config.json`). A changeset that bumps any one of them bumps all of them to the same version. You only need to list the packages that actually changed in the changeset front matter — the fixed group handles the rest.
|
||||
|
||||
### Preparing a release
|
||||
|
||||
When asked to prepare a release (e.g. "prepare v0.12.0"):
|
||||
|
||||
1. **Create a changeset file** at `.changeset/v0-<N>-release.md` following the existing pattern:
|
||||
- YAML front matter listing changed packages with bump type (`minor` for feature releases, `patch` for bug-fix-only releases)
|
||||
- A one-line summary, then `### New:` / `### Improved:` / `### Fixed:` sections describing each change
|
||||
- Always list `@json-render/core` plus any packages with actual code changes
|
||||
2. **Do NOT bump versions** in `package.json` files — CI runs `pnpm ci:version` (which calls `changeset version`) to do that automatically
|
||||
3. **Do NOT manually write `CHANGELOG.md`** entries — `changeset version` generates them from the changeset file
|
||||
4. **Add new packages to the fixed group** in `.changeset/config.json` if they should be versioned together with the rest
|
||||
5. **Fill documentation gaps** — every public package should have:
|
||||
- A row in the root `README.md` packages table
|
||||
- A renderer section in the root `README.md` (if it's a renderer)
|
||||
- An API reference page at `apps/web/app/(main)/docs/api/<name>/page.mdx`
|
||||
- An entry in `apps/web/lib/page-titles.ts` and `apps/web/lib/docs-navigation.ts`
|
||||
- An entry in the docs-chat system prompt (`apps/web/app/api/docs-chat/route.ts`)
|
||||
- A skill at `skills/json-render-<name>/SKILL.md`
|
||||
- A `packages/<name>/README.md`
|
||||
6. **Run `pnpm type-check`** after all changes to verify nothing is broken
|
||||
|
||||
### CI scripts
|
||||
|
||||
- `pnpm changeset` — interactively create a new changeset
|
||||
- `pnpm ci:version` — run `changeset version` + lockfile update (CI only)
|
||||
- `pnpm ci:publish` — build all packages and publish to npm (CI only)
|
||||
|
||||
<!-- opensrc:start -->
|
||||
|
||||
## Source Code Reference
|
||||
|
||||
@@ -118,6 +118,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/image` | Image renderer for SVG/PNG output (OG images, social cards) via Satori |
|
||||
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||
@@ -287,6 +288,35 @@ const spec = {
|
||||
const buffer = await renderToBuffer(spec);
|
||||
```
|
||||
|
||||
### Image (SVG/PNG)
|
||||
|
||||
```typescript
|
||||
import { renderToPng } from "@json-render/image/render";
|
||||
|
||||
const 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: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Render to PNG (requires @resvg/resvg-js)
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
|
||||
// Or render to SVG string
|
||||
import { renderToSvg } from "@json-render/image/render";
|
||||
const svg = await renderToSvg(spec, { fonts });
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Streaming (SpecStream)
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# web
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/codegen@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/image")
|
||||
|
||||
# @json-render/image
|
||||
|
||||
Image renderer. Turn JSON specs into SVG and PNG images using [Satori](https://github.com/vercel/satori).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/image
|
||||
```
|
||||
|
||||
For PNG output, also install the optional peer dependency:
|
||||
|
||||
```bash
|
||||
npm install @resvg/resvg-js
|
||||
```
|
||||
|
||||
See the [Image example](https://github.com/vercel-labs/json-render/tree/main/examples/image) for a full working example.
|
||||
|
||||
## schema
|
||||
|
||||
The image element schema for image specs. Use with `defineCatalog` from core.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema, standardComponentDefinitions } from '@json-render/image';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
## Render Functions
|
||||
|
||||
Server-side functions for producing image output. Both accept a spec and optional `RenderOptions`.
|
||||
|
||||
```typescript
|
||||
import { renderToSvg, renderToPng } from '@json-render/image/render';
|
||||
|
||||
const svg = await renderToSvg(spec, { fonts });
|
||||
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
await writeFile('output.png', png);
|
||||
```
|
||||
|
||||
### RenderOptions
|
||||
|
||||
```typescript
|
||||
interface RenderOptions {
|
||||
registry?: ComponentRegistry;
|
||||
includeStandard?: boolean; // default: true
|
||||
state?: Record<string, unknown>;
|
||||
fonts?: SatoriOptions['fonts'];
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
```
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Type</th>
|
||||
<th>Default</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>fonts</code></td>
|
||||
<td><code>{"SatoriOptions['fonts']"}</code></td>
|
||||
<td><code>[]</code></td>
|
||||
<td>Font data for text rendering (required for meaningful output)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>width</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td>Frame prop</td>
|
||||
<td>Override the output image width</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>height</code></td>
|
||||
<td><code>number</code></td>
|
||||
<td>Frame prop</td>
|
||||
<td>Override the output image height</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>registry</code></td>
|
||||
<td><code>{"Record<string, ComponentRenderer>"}</code></td>
|
||||
<td><code>{"{}"}</code></td>
|
||||
<td>Custom component map (merged with standard components)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>includeStandard</code></td>
|
||||
<td><code>boolean</code></td>
|
||||
<td><code>true</code></td>
|
||||
<td>Include built-in standard components</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>state</code></td>
|
||||
<td><code>{"Record<string, unknown>"}</code></td>
|
||||
<td><code>{"{}"}</code></td>
|
||||
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Root
|
||||
|
||||
#### Frame
|
||||
|
||||
Root image container. Defines the output image dimensions and background. Must be the root element.
|
||||
|
||||
```typescript
|
||||
{
|
||||
width: number;
|
||||
height: number;
|
||||
backgroundColor: string | null;
|
||||
padding: number | null;
|
||||
display: "flex" | "none" | null;
|
||||
flexDirection: "row" | "column" | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
#### Box
|
||||
|
||||
Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.
|
||||
|
||||
```typescript
|
||||
{
|
||||
padding: number | null;
|
||||
paddingTop: number | null;
|
||||
paddingBottom: number | null;
|
||||
paddingLeft: number | null;
|
||||
paddingRight: number | null;
|
||||
margin: number | null;
|
||||
backgroundColor: string | null;
|
||||
borderWidth: number | null;
|
||||
borderColor: string | null;
|
||||
borderRadius: number | null;
|
||||
flex: number | null;
|
||||
width: number | string | null;
|
||||
height: number | string | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
flexDirection: "row" | "column" | null;
|
||||
position: "relative" | "absolute" | null;
|
||||
top: number | null;
|
||||
left: number | null;
|
||||
right: number | null;
|
||||
bottom: number | null;
|
||||
overflow: "visible" | "hidden" | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Row
|
||||
|
||||
Horizontal flex layout with optional wrapping.
|
||||
|
||||
```typescript
|
||||
{
|
||||
gap: number | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
padding: number | null;
|
||||
flex: number | null;
|
||||
wrap: boolean | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Column
|
||||
|
||||
Vertical flex layout.
|
||||
|
||||
```typescript
|
||||
{
|
||||
gap: number | null;
|
||||
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||
padding: number | null;
|
||||
flex: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Content
|
||||
|
||||
#### Heading
|
||||
|
||||
Heading text at various levels. h1 is largest, h4 is smallest.
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
level: "h1" | "h2" | "h3" | "h4" | null;
|
||||
color: string | null;
|
||||
align: "left" | "center" | "right" | null;
|
||||
letterSpacing: number | string | null;
|
||||
lineHeight: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Text
|
||||
|
||||
Body text with configurable size, color, weight, and alignment.
|
||||
|
||||
```typescript
|
||||
{
|
||||
text: string;
|
||||
fontSize: number | null;
|
||||
color: string | null;
|
||||
align: "left" | "center" | "right" | null;
|
||||
fontWeight: "normal" | "bold" | null;
|
||||
fontStyle: "normal" | "italic" | null;
|
||||
lineHeight: number | null;
|
||||
letterSpacing: number | string | null;
|
||||
textDecoration: "none" | "underline" | "line-through" | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
Image from a URL with optional dimensions and fit.
|
||||
|
||||
```typescript
|
||||
{
|
||||
src: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
borderRadius: number | null;
|
||||
objectFit: "contain" | "cover" | "fill" | "none" | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Decorative
|
||||
|
||||
#### Divider
|
||||
|
||||
Horizontal line separator.
|
||||
|
||||
```typescript
|
||||
{
|
||||
color: string | null;
|
||||
thickness: number | null;
|
||||
marginTop: number | null;
|
||||
marginBottom: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
#### Spacer
|
||||
|
||||
Empty vertical space.
|
||||
|
||||
```typescript
|
||||
{
|
||||
height: number | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Catalog Definitions
|
||||
|
||||
Pre-built definitions for creating image catalogs:
|
||||
|
||||
```typescript
|
||||
import { standardComponentDefinitions } from '@json-render/image/catalog';
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/image';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...standardComponentDefinitions,
|
||||
// Add custom components
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React or Satori:
|
||||
|
||||
```typescript
|
||||
import { schema, standardComponentDefinitions } from '@json-render/image/server';
|
||||
```
|
||||
|
||||
## Sub-path Exports
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Export</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>@json-render/image</code></td>
|
||||
<td>Full package: schema, renderer, components, render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/image/server</code></td>
|
||||
<td>Schema and catalog definitions only (no React or Satori)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/image/catalog</code></td>
|
||||
<td>Standard component definitions and types</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/image/render</code></td>
|
||||
<td>Server-side render functions only</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Types
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Export</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>ImageSchema</code></td>
|
||||
<td>Schema type for image specs</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ImageSpec</code></td>
|
||||
<td>Spec type for image output</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>RenderOptions</code></td>
|
||||
<td>Options for render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentRenderProps</code></td>
|
||||
<td>Props passed to component render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentRenderer</code></td>
|
||||
<td>Component render function type</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ComponentRegistry</code></td>
|
||||
<td>Map of component names to render functions</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>StandardComponentDefinitions</code></td>
|
||||
<td>Type of the standard component definitions object</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>StandardComponentProps{'<K>'}</code></td>
|
||||
<td>Inferred props type for a standard component by name</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,197 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/renderers")
|
||||
|
||||
# Renderers
|
||||
|
||||
json-render supports multiple output targets. Each renderer takes the same core concept -- a JSON spec constrained to a catalog -- and renders it natively on a different platform or into a different format.
|
||||
|
||||
All renderers share the same workflow:
|
||||
|
||||
1. Define a catalog with `defineCatalog`
|
||||
2. AI generates a JSON spec
|
||||
3. The renderer turns the spec into platform-native output
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Renderer</th>
|
||||
<th>Package</th>
|
||||
<th>Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>React</td>
|
||||
<td><code>@json-render/react</code></td>
|
||||
<td>React component tree</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Vue</td>
|
||||
<td><code>@json-render/vue</code></td>
|
||||
<td>Vue 3 component tree</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>shadcn/ui</td>
|
||||
<td><code>@json-render/shadcn</code></td>
|
||||
<td>Pre-built Radix UI + Tailwind components (uses React renderer)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>React Native</td>
|
||||
<td><code>@json-render/react-native</code></td>
|
||||
<td>Native mobile views</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td><code>@json-render/image</code></td>
|
||||
<td>SVG / PNG (via Satori)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>React PDF</td>
|
||||
<td><code>@json-render/react-pdf</code></td>
|
||||
<td>PDF documents</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Remotion</td>
|
||||
<td><code>@json-render/remotion</code></td>
|
||||
<td>Video compositions</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## React
|
||||
|
||||
Render specs as React component trees in the browser. Supports data binding, streaming, actions, validation, visibility, and computed values.
|
||||
|
||||
```tsx
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
Use `StateProvider`, `VisibilityProvider`, and `ActionProvider` for full interactivity. See the [@json-render/react API reference](/docs/api/react) for details.
|
||||
|
||||
## Vue
|
||||
|
||||
Vue 3 renderer with full feature parity with React: data binding, visibility, actions, validation, repeat scopes, and streaming.
|
||||
|
||||
```typescript
|
||||
import { defineRegistry, Renderer } from "@json-render/vue";
|
||||
import { schema } from "@json-render/vue/schema";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: ({ props, children }) =>
|
||||
h("div", { class: "card" }, [h("h3", null, props.title), children]),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Uses composables (`useStateStore`, `useStateBinding`, `useActions`, etc.) instead of React hooks. See the [@json-render/vue API reference](/docs/api/vue) for details.
|
||||
|
||||
## shadcn/ui
|
||||
|
||||
36 pre-built components using Radix UI and Tailwind CSS. Built on top of `@json-render/react` -- no custom renderer needed.
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { defineRegistry, Renderer } from "@json-render/react";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
},
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Button: shadcnComponents.Button,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
See the [@json-render/shadcn API reference](/docs/api/shadcn) for the full component list.
|
||||
|
||||
## React Native
|
||||
|
||||
Render specs as native mobile views. Includes 25+ standard components and standard action definitions.
|
||||
|
||||
```tsx
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react-native/schema";
|
||||
import { standardComponentDefinitions, standardActionDefinitions } from "@json-render/react-native/catalog";
|
||||
import { defineRegistry, Renderer } from "@json-render/react-native";
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: { ...standardComponentDefinitions },
|
||||
actions: standardActionDefinitions,
|
||||
});
|
||||
|
||||
const { registry } = defineRegistry(catalog, { components: {} });
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
```
|
||||
|
||||
See the [@json-render/react-native API reference](/docs/api/react-native) for details.
|
||||
|
||||
## Image
|
||||
|
||||
Generate SVG and PNG images from JSON specs using Satori. Ideal for OG images, social cards, and banners.
|
||||
|
||||
```typescript
|
||||
import { renderToSvg, renderToPng } from "@json-render/image/render";
|
||||
|
||||
const svg = await renderToSvg(spec, { fonts });
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
```
|
||||
|
||||
Nine standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer. PNG output requires `@resvg/resvg-js` as an optional peer dependency.
|
||||
|
||||
See the [@json-render/image API reference](/docs/api/image) for details.
|
||||
|
||||
## React PDF
|
||||
|
||||
Generate PDF documents from JSON specs using `@react-pdf/renderer`. Render to buffer, stream, or file.
|
||||
|
||||
```typescript
|
||||
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
|
||||
|
||||
const buffer = await renderToBuffer(spec);
|
||||
const stream = await renderToStream(spec);
|
||||
await renderToFile(spec, "./output.pdf");
|
||||
```
|
||||
|
||||
Standard components include Document, Page, View, Row, Column, Heading, Text, Image, Table, List, Divider, Spacer, Link, and PageNumber.
|
||||
|
||||
See the [@json-render/react-pdf API reference](/docs/api/react-pdf) for details.
|
||||
|
||||
## Remotion
|
||||
|
||||
Turn JSON timeline specs into video compositions with Remotion.
|
||||
|
||||
```tsx
|
||||
import { Player } from "@remotion/player";
|
||||
import { Renderer } from "@json-render/remotion";
|
||||
|
||||
<Player
|
||||
component={Renderer}
|
||||
inputProps={{ spec }}
|
||||
durationInFrames={spec.composition.durationInFrames}
|
||||
fps={spec.composition.fps}
|
||||
compositionWidth={spec.composition.width}
|
||||
compositionHeight={spec.composition.height}
|
||||
/>
|
||||
```
|
||||
|
||||
Uses a timeline spec format with compositions, tracks, and clips. Includes standard components (TitleCard, TypingText, ImageSlide, etc.), transitions (fade, slide, zoom, wipe), and effects.
|
||||
|
||||
See the [@json-render/remotion API reference](/docs/api/remotion) for details.
|
||||
|
||||
## Custom Renderers
|
||||
|
||||
You can build your own renderer for any output target. See the [Custom Schema & Renderer](/docs/custom-schema) guide for how to define a custom schema and wire it to your own rendering logic.
|
||||
@@ -16,7 +16,7 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/image, @json-render/remotion, @json-render/codegen
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{
|
||||
title: "Rendering",
|
||||
items: [
|
||||
{ title: "Renderers", href: "/docs/renderers" },
|
||||
{ title: "Registry", href: "/docs/registry" },
|
||||
{ title: "Streaming", href: "/docs/streaming" },
|
||||
{ title: "Generation Modes", href: "/docs/generation-modes" },
|
||||
@@ -69,6 +70,11 @@ export const docsNavigation: NavSection[] = [
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Image",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/image",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: "Vue",
|
||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/vue",
|
||||
@@ -106,6 +112,7 @@ export const docsNavigation: NavSection[] = [
|
||||
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||
{ title: "@json-render/image", href: "/docs/api/image" },
|
||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||
{ title: "@json-render/vue", href: "/docs/api/vue" },
|
||||
{ title: "@json-render/codegen", href: "/docs/api/codegen" },
|
||||
|
||||
@@ -26,6 +26,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/computed-values": "Computed Values",
|
||||
"docs/visibility": "Visibility",
|
||||
"docs/watchers": "Watchers",
|
||||
"docs/renderers": "Renderers",
|
||||
"docs/generation-modes": "Generation Modes",
|
||||
"docs/code-export": "Code Export",
|
||||
"docs/custom-schema": "Custom Schema & Renderer",
|
||||
@@ -44,6 +45,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||
"docs/api/react-native": "@json-render/react-native API",
|
||||
"docs/api/codegen": "@json-render/codegen API",
|
||||
"docs/api/image": "@json-render/image API",
|
||||
"docs/api/remotion": "@json-render/remotion API",
|
||||
"docs/api/shadcn": "@json-render/shadcn API",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -50,7 +50,7 @@
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"unist-util-visit": "5.1.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-chat
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
- @json-render/shadcn@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-chat",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -33,7 +33,7 @@
|
||||
"streamdown": "^2.2.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"three": "^0.182.0",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-dashboard
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/codegen@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-dashboard",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -39,7 +39,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# example-image
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/image@0.11.0
|
||||
@@ -0,0 +1,34 @@
|
||||
import { streamText } from "ai";
|
||||
import { buildUserPrompt, type Spec } from "@json-render/core";
|
||||
import { imageCatalog } from "@/lib/catalog";
|
||||
|
||||
export const maxDuration = 60;
|
||||
|
||||
const SYSTEM_PROMPT = imageCatalog.prompt();
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const { prompt, startingSpec } = (await req.json()) as {
|
||||
prompt: string;
|
||||
startingSpec?: Spec | null;
|
||||
};
|
||||
|
||||
if (!prompt || typeof prompt !== "string") {
|
||||
return Response.json({ error: "prompt is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const userPrompt = buildUserPrompt({
|
||||
prompt,
|
||||
currentSpec: startingSpec,
|
||||
});
|
||||
|
||||
const result = streamText({
|
||||
model: process.env.AI_GATEWAY_MODEL ?? DEFAULT_MODEL,
|
||||
system: SYSTEM_PROMPT,
|
||||
prompt: userPrompt,
|
||||
temperature: 0.7,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { renderToPng } from "@json-render/image/render";
|
||||
import { examples } from "@/lib/examples";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
let fontCache: ArrayBuffer | null = null;
|
||||
|
||||
async function loadFont(): Promise<ArrayBuffer> {
|
||||
if (fontCache) return fontCache;
|
||||
const fontPath = join(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
"geist",
|
||||
"dist",
|
||||
"fonts",
|
||||
"geist-sans",
|
||||
"Geist-Regular.ttf",
|
||||
);
|
||||
const buffer = await readFile(fontPath);
|
||||
fontCache = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
);
|
||||
return fontCache;
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const name = searchParams.get("name") ?? "og-image";
|
||||
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 imageResponse(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 imageResponse(spec, filename ?? "image", download ?? false);
|
||||
}
|
||||
|
||||
async function imageResponse(spec: Spec, name: string, download: boolean) {
|
||||
const fontData = await loadFont();
|
||||
const fonts = [
|
||||
{
|
||||
name: "Geist Sans",
|
||||
data: fontData,
|
||||
weight: 400 as const,
|
||||
style: "normal" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
|
||||
const disposition = download
|
||||
? `attachment; filename="${name}.png"`
|
||||
: `inline; filename="${name}.png"`;
|
||||
|
||||
return new Response(Buffer.from(png), {
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Disposition": disposition,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geist = Geist({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "json-render Image Example",
|
||||
description: "Generate images from JSON specs with @json-render/image",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={geist.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,627 @@
|
||||
"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 { ImageIcon, Download, Loader2, ArrowRight, Square } from "lucide-react";
|
||||
|
||||
type Mode = "scratch" | "example";
|
||||
type MobileView = "json" | "preview";
|
||||
|
||||
interface Selection {
|
||||
mode: Mode;
|
||||
exampleName?: string;
|
||||
}
|
||||
|
||||
const IMAGE_REFRESH_INTERVAL_MS = 3000;
|
||||
|
||||
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];
|
||||
return root?.type === "Frame";
|
||||
}
|
||||
|
||||
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 [imageUrl, setImageUrl] = 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 imageUrlRef = 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 exampleImageUrl =
|
||||
selection.mode === "example" && !generatedSpec
|
||||
? `/api/image?name=${selection.exampleName}`
|
||||
: null;
|
||||
|
||||
const displayImageUrl = imageUrl ?? exampleImageUrl;
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selection.mode, selection.exampleName]);
|
||||
|
||||
const fetchImageBlob = useCallback(
|
||||
async (spec: Spec, signal?: AbortSignal) => {
|
||||
const res = await fetch("/api/image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ spec }),
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to generate image");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const prev = imageUrlRef.current;
|
||||
imageUrlRef.current = url;
|
||||
setImageUrl(url);
|
||||
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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);
|
||||
fetchImageBlob(spec)
|
||||
.catch(() => {})
|
||||
.finally(() => setRefreshing(false));
|
||||
}, IMAGE_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [generating, fetchImageBlob]);
|
||||
|
||||
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 fetchImageBlob(finalSpec);
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) return;
|
||||
setError(e instanceof Error ? e.message : "Something went wrong");
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [prompt, selection, currentExample, fetchImageBlob]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
setGenerating(false);
|
||||
|
||||
if (isRenderableSpec(generatedSpec)) {
|
||||
fetchImageBlob(generatedSpec).catch(() => {});
|
||||
}
|
||||
}, [generatedSpec, fetchImageBlob]);
|
||||
|
||||
const select = (next: Selection) => {
|
||||
abortRef.current?.abort();
|
||||
setSelection(next);
|
||||
setGeneratedSpec(null);
|
||||
setImageUrl(null);
|
||||
setError(null);
|
||||
setPrompt("");
|
||||
setGenerating(false);
|
||||
setExamplesSheetOpen(false);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!activeSpec) return;
|
||||
if (generatedSpec) {
|
||||
const res = await fetch("/api/image", {
|
||||
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 = "image.png";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} else if (selection.mode === "example") {
|
||||
window.open(
|
||||
`/api/image?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 an image";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
json-render / image
|
||||
</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 image 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: Image 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.5 w-3.5 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-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||
{displayImageUrl ? (
|
||||
<img
|
||||
key={displayImageUrl}
|
||||
src={displayImageUrl}
|
||||
alt="Generated image"
|
||||
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<ImageIcon className="h-10 w-10" />
|
||||
<p className="text-sm">
|
||||
{selection.mode === "scratch"
|
||||
? "Enter a prompt to generate an image"
|
||||
: "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 h-10 flex items-center px-1">
|
||||
<button
|
||||
onClick={() => setExamplesSheetOpen(true)}
|
||||
className="px-3 h-full text-xs font-mono text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
{selection.mode === "example" && currentExample
|
||||
? currentExample.label
|
||||
: "scratch"}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setMobileView("json")}
|
||||
className={cn(
|
||||
"px-3 h-full text-xs font-mono transition-colors",
|
||||
mobileView === "json"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
json
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMobileView("preview")}
|
||||
className={cn(
|
||||
"px-3 h-full text-xs font-mono transition-colors",
|
||||
mobileView === "preview"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
preview
|
||||
</button>
|
||||
{activeSpec && (
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="px-3 h-full text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{mobileView === "json" ? (
|
||||
<div ref={mobileCodeScrollRef} className="h-full overflow-auto">
|
||||
<pre className="p-3 text-xs leading-relaxed font-mono text-muted-foreground whitespace-pre">
|
||||
{jsonCode}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full bg-neutral-100 dark:bg-neutral-800 flex items-center justify-center p-4">
|
||||
{displayImageUrl ? (
|
||||
<img
|
||||
key={displayImageUrl}
|
||||
src={displayImageUrl}
|
||||
alt="Generated image"
|
||||
className="max-w-full max-h-full object-contain rounded shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<ImageIcon className="h-10 w-10" />
|
||||
<p className="text-sm">
|
||||
{selection.mode === "scratch"
|
||||
? "Enter a prompt to generate an image"
|
||||
: "Select an example"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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();
|
||||
mobileInputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-2 rounded bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={mobileInputRef}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
selection.mode === "scratch"
|
||||
? "Describe the image 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}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
{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>
|
||||
|
||||
<Sheet open={examplesSheetOpen} onOpenChange={setExamplesSheetOpen}>
|
||||
<SheetContent side="left" className="w-72 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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||
|
||||
/** @type {import("eslint").Linter.Config[]} */
|
||||
export default [
|
||||
...nextJsConfig,
|
||||
{
|
||||
rules: {
|
||||
"react/prop-types": "off",
|
||||
"@next/next/no-img-element": "off",
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/image/server";
|
||||
import { standardComponentDefinitions } from "@json-render/image/catalog";
|
||||
|
||||
export const imageCatalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
});
|
||||
@@ -0,0 +1,771 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
export interface Example {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
spec: Spec;
|
||||
}
|
||||
|
||||
export const examples: Example[] = [
|
||||
// ==========================================================================
|
||||
// OG Image
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "og-image",
|
||||
label: "OG Image",
|
||||
description: "Social sharing card for Open Graph (1200x630)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#0f172a",
|
||||
padding: 60,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["top-row", "content", "bottom-row"],
|
||||
},
|
||||
"top-row": {
|
||||
type: "Row",
|
||||
props: { gap: 12, alignItems: "center" },
|
||||
children: ["logo-dot", "brand"],
|
||||
},
|
||||
"logo-dot": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 16,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
brand: {
|
||||
type: "Text",
|
||||
props: { text: "acme.dev", fontSize: 24, color: "#94a3b8" },
|
||||
children: [],
|
||||
},
|
||||
content: {
|
||||
type: "Column",
|
||||
props: { gap: 16 },
|
||||
children: ["title", "subtitle"],
|
||||
},
|
||||
title: {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "Build faster with the modern developer platform",
|
||||
level: "h1",
|
||||
color: "#f8fafc",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.1,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Ship production-ready apps in minutes, not months.",
|
||||
fontSize: 24,
|
||||
color: "#94a3b8",
|
||||
lineHeight: 1.4,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"bottom-row": {
|
||||
type: "Row",
|
||||
props: { gap: 8, alignItems: "center" },
|
||||
children: ["url"],
|
||||
},
|
||||
url: {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "acme.dev/platform",
|
||||
fontSize: 18,
|
||||
color: "#6366f1",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Blog Post Card
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "blog-card",
|
||||
label: "Blog Post Card",
|
||||
description: "Social card for a blog post with author info",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 60,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["header", "body", "footer"],
|
||||
},
|
||||
header: {
|
||||
type: "Row",
|
||||
props: { gap: 10, alignItems: "center" },
|
||||
children: ["category-badge"],
|
||||
},
|
||||
"category-badge": {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#dbeafe",
|
||||
borderRadius: 20,
|
||||
paddingTop: 6,
|
||||
paddingBottom: 6,
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
},
|
||||
children: ["category-text"],
|
||||
},
|
||||
"category-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Engineering",
|
||||
fontSize: 16,
|
||||
color: "#1d4ed8",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
body: {
|
||||
type: "Column",
|
||||
props: { gap: 20 },
|
||||
children: ["blog-title", "blog-excerpt"],
|
||||
},
|
||||
"blog-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "How We Reduced API Latency by 90% with Edge Computing",
|
||||
level: "h1",
|
||||
color: "#0f172a",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.15,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"blog-excerpt": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "A deep dive into our migration from centralized servers to edge functions, and the performance gains we achieved along the way.",
|
||||
fontSize: 22,
|
||||
color: "#64748b",
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
footer: {
|
||||
type: "Row",
|
||||
props: { gap: 16, alignItems: "center" },
|
||||
children: ["author-avatar", "author-info"],
|
||||
},
|
||||
"author-avatar": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
backgroundColor: "#e2e8f0",
|
||||
borderRadius: 24,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-info": {
|
||||
type: "Column",
|
||||
props: { gap: 2 },
|
||||
children: ["author-name", "author-date"],
|
||||
},
|
||||
"author-name": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Sarah Chen",
|
||||
fontSize: 18,
|
||||
color: "#0f172a",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-date": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "February 15, 2026",
|
||||
fontSize: 16,
|
||||
color: "#94a3b8",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Event Banner
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "event-banner",
|
||||
label: "Event Banner",
|
||||
description: "Wide promotional banner for an event (1920x1080)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
backgroundColor: "#18181b",
|
||||
padding: 100,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: [
|
||||
"badge-row",
|
||||
"spacer-1",
|
||||
"event-title",
|
||||
"spacer-2",
|
||||
"details-row",
|
||||
"spacer-3",
|
||||
"cta",
|
||||
],
|
||||
},
|
||||
"badge-row": {
|
||||
type: "Row",
|
||||
props: { alignItems: "center", justifyContent: "center" },
|
||||
children: ["live-badge"],
|
||||
},
|
||||
"live-badge": {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#dc2626",
|
||||
borderRadius: 24,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 8,
|
||||
paddingLeft: 24,
|
||||
paddingRight: 24,
|
||||
},
|
||||
children: ["live-text"],
|
||||
},
|
||||
"live-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "LIVE EVENT",
|
||||
fontSize: 18,
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.1em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-1": {
|
||||
type: "Spacer",
|
||||
props: { height: 40 },
|
||||
children: [],
|
||||
},
|
||||
"event-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "DevConf 2026",
|
||||
level: "h1",
|
||||
color: "#ffffff",
|
||||
align: "center",
|
||||
letterSpacing: "-0.03em",
|
||||
lineHeight: 1.0,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-2": {
|
||||
type: "Spacer",
|
||||
props: { height: 32 },
|
||||
children: [],
|
||||
},
|
||||
"details-row": {
|
||||
type: "Row",
|
||||
props: {
|
||||
gap: 40,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: ["date-text", "dot-sep", "location-text"],
|
||||
},
|
||||
"date-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "March 15-17, 2026",
|
||||
fontSize: 28,
|
||||
color: "#a1a1aa",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"dot-sep": {
|
||||
type: "Text",
|
||||
props: { text: "/", fontSize: 28, color: "#52525b" },
|
||||
children: [],
|
||||
},
|
||||
"location-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "San Francisco, CA",
|
||||
fontSize: 28,
|
||||
color: "#a1a1aa",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"spacer-3": {
|
||||
type: "Spacer",
|
||||
props: { height: 48 },
|
||||
children: [],
|
||||
},
|
||||
cta: {
|
||||
type: "Box",
|
||||
props: {
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 12,
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
paddingLeft: 48,
|
||||
paddingRight: 48,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
children: ["cta-text"],
|
||||
},
|
||||
"cta-text": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Register Now",
|
||||
fontSize: 24,
|
||||
color: "#ffffff",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.02em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Social Square
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "social-square",
|
||||
label: "Social Square",
|
||||
description: "Square format card for Instagram or social media (1080x1080)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1080,
|
||||
height: 1080,
|
||||
backgroundColor: "#faf5ff",
|
||||
padding: 80,
|
||||
flexDirection: "column",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["top-section", "quote-section", "bottom-section"],
|
||||
},
|
||||
"top-section": {
|
||||
type: "Row",
|
||||
props: {
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
children: ["quote-icon", "slide-number"],
|
||||
},
|
||||
"quote-icon": {
|
||||
type: "Heading",
|
||||
props: { text: '"', level: "h1", color: "#9333ea" },
|
||||
children: [],
|
||||
},
|
||||
"slide-number": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "01 / 05",
|
||||
fontSize: 18,
|
||||
color: "#a855f7",
|
||||
fontWeight: "bold",
|
||||
letterSpacing: "0.1em",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"quote-section": {
|
||||
type: "Column",
|
||||
props: { gap: 24 },
|
||||
children: ["quote-text"],
|
||||
},
|
||||
"quote-text": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "The best way to predict the future is to create it.",
|
||||
level: "h2",
|
||||
color: "#1e1b4b",
|
||||
letterSpacing: "-0.01em",
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"bottom-section": {
|
||||
type: "Column",
|
||||
props: { gap: 4 },
|
||||
children: ["author-name-sq", "author-title"],
|
||||
},
|
||||
"author-name-sq": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Peter Drucker",
|
||||
fontSize: 22,
|
||||
color: "#1e1b4b",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"author-title": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Management Consultant & Author",
|
||||
fontSize: 18,
|
||||
color: "#7c3aed",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Bar Graph
|
||||
// ==========================================================================
|
||||
{
|
||||
name: "bar-graph",
|
||||
label: "Bar Graph",
|
||||
description: "Data visualization bar chart (1200x630)",
|
||||
spec: {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
backgroundColor: "#ffffff",
|
||||
padding: 48,
|
||||
flexDirection: "column",
|
||||
},
|
||||
children: ["header", "chart-area"],
|
||||
},
|
||||
header: {
|
||||
type: "Column",
|
||||
props: { gap: 4 },
|
||||
children: ["chart-title", "chart-subtitle"],
|
||||
},
|
||||
"chart-title": {
|
||||
type: "Heading",
|
||||
props: {
|
||||
text: "Monthly Revenue",
|
||||
level: "h2",
|
||||
color: "#0f172a",
|
||||
letterSpacing: "-0.02em",
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"chart-subtitle": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Q3-Q4 2025 (in thousands USD)",
|
||||
fontSize: 18,
|
||||
color: "#94a3b8",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"chart-area": {
|
||||
type: "Row",
|
||||
props: {
|
||||
gap: 0,
|
||||
alignItems: "flex-end",
|
||||
justifyContent: "space-between",
|
||||
flex: 1,
|
||||
},
|
||||
children: [
|
||||
"bar-jul",
|
||||
"bar-aug",
|
||||
"bar-sep",
|
||||
"bar-oct",
|
||||
"bar-nov",
|
||||
"bar-dec",
|
||||
],
|
||||
},
|
||||
|
||||
"bar-jul": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-jul", "rect-jul", "lbl-jul"],
|
||||
},
|
||||
"val-jul": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$42k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-jul": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 168,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-jul": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Jul",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-aug": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-aug", "rect-aug", "lbl-aug"],
|
||||
},
|
||||
"val-aug": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$58k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-aug": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 232,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-aug": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Aug",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-sep": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-sep", "rect-sep", "lbl-sep"],
|
||||
},
|
||||
"val-sep": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$51k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-sep": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 204,
|
||||
backgroundColor: "#6366f1",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-sep": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Sep",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-oct": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-oct", "rect-oct", "lbl-oct"],
|
||||
},
|
||||
"val-oct": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$73k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-oct": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 292,
|
||||
backgroundColor: "#818cf8",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-oct": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Oct",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-nov": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-nov", "rect-nov", "lbl-nov"],
|
||||
},
|
||||
"val-nov": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$85k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-nov": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 340,
|
||||
backgroundColor: "#818cf8",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-nov": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Nov",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
|
||||
"bar-dec": {
|
||||
type: "Column",
|
||||
props: {
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flex: 1,
|
||||
},
|
||||
children: ["val-dec", "rect-dec", "lbl-dec"],
|
||||
},
|
||||
"val-dec": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "$97k",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"rect-dec": {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 80,
|
||||
height: 388,
|
||||
backgroundColor: "#4f46e5",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
"lbl-dec": {
|
||||
type: "Text",
|
||||
props: {
|
||||
text: "Dec",
|
||||
fontSize: 16,
|
||||
color: "#64748b",
|
||||
align: "center",
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@resvg/resvg-js", "satori"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "example-image",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless image-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"check-types": "tsc --noEmit",
|
||||
"lint": "eslint --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/image": "workspace:*",
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"satori": "^0.19.2",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"ai": "6.0.103",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"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.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../packages/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"plugins": [{ "name": "next" }],
|
||||
"declaration": false,
|
||||
"declarationMap": false,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
# example-no-ai
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
- @json-render/shadcn@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-no-ai",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -23,7 +23,7 @@
|
||||
"react-dom": "19.2.4",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-native
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react-native@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-native",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"react-native": "0.81.4",
|
||||
"react-native-safe-area-context": "~5.4.0",
|
||||
"react-native-screens": "~4.11.1",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-react-pdf
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react-pdf@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-react-pdf",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -25,7 +25,7 @@
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"zod": "4.3.5"
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-remotion
|
||||
|
||||
## 0.1.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/remotion@0.11.0
|
||||
|
||||
## 0.1.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-remotion",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -26,7 +26,7 @@
|
||||
"remotion": "4.0.418",
|
||||
"shiki": "^3.21.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/eslint-config": "workspace:*",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-demo
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-demo",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "Test",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
@@ -10,7 +10,7 @@
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "^9.1.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# com.example.json-render-fullpage-demo
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "com.example.json-render-fullpage-demo",
|
||||
"version": "0.0.4",
|
||||
"version": "0.0.5",
|
||||
"description": "Full-page Stripe App example (alpha)",
|
||||
"private": true,
|
||||
"license": "~~proprietary~~",
|
||||
@@ -10,7 +10,7 @@
|
||||
"@json-render/react": "workspace:*",
|
||||
"@stripe/ui-extension-sdk": "9.2.0-alpha.0",
|
||||
"stripe": "^13.11.0",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# vite-renderers
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
- @json-render/vue@0.11.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vite-renderers",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vite-renderers.json-render vite",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# example-vue
|
||||
|
||||
## 0.1.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/vue@0.11.0
|
||||
|
||||
## 0.1.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-vue",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "portless vue.json-render vite",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/codegen
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/codegen",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Utilities for generating code from json-render UI trees",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
# @json-render/core
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3f1e71e: Image renderer: generate SVG and PNG from JSON specs.
|
||||
|
||||
### New: `@json-render/image` Package
|
||||
|
||||
Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners.
|
||||
- `renderToSvg(spec, options)` — render spec to SVG string
|
||||
- `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`)
|
||||
- 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer
|
||||
- `standardComponentDefinitions` catalog for AI prompt generation
|
||||
- Server-safe import path: `@json-render/image/server`
|
||||
- Sub-path exports: `/render`, `/catalog`, `/server`
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/core",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
|
||||
"keywords": [
|
||||
@@ -50,7 +50,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# @json-render/image
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 3f1e71e: Image renderer: generate SVG and PNG from JSON specs.
|
||||
|
||||
### New: `@json-render/image` Package
|
||||
|
||||
Server-side image renderer powered by Satori. Turns the same `{ root, elements }` spec format into SVG or PNG output for OG images, social cards, and banners.
|
||||
- `renderToSvg(spec, options)` — render spec to SVG string
|
||||
- `renderToPng(spec, options)` — render spec to PNG buffer (requires `@resvg/resvg-js`)
|
||||
- 9 standard components: Frame, Box, Row, Column, Heading, Text, Image, Divider, Spacer
|
||||
- `standardComponentDefinitions` catalog for AI prompt generation
|
||||
- Server-safe import path: `@json-render/image/server`
|
||||
- Sub-path exports: `/render`, `/catalog`, `/server`
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
@@ -0,0 +1,167 @@
|
||||
# @json-render/image
|
||||
|
||||
Image renderer for `@json-render/core`. Generate SVG and PNG images from JSON specs using [Satori](https://github.com/vercel/satori).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/image
|
||||
```
|
||||
|
||||
For PNG output, also install the optional peer dependency:
|
||||
|
||||
```bash
|
||||
npm install @resvg/resvg-js
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Render a spec to SVG
|
||||
|
||||
```typescript
|
||||
import { renderToSvg } 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", "subtitle"],
|
||||
},
|
||||
heading: {
|
||||
type: "Heading",
|
||||
props: { text: "Hello World", level: "h1", color: "#ffffff" },
|
||||
children: [],
|
||||
},
|
||||
subtitle: {
|
||||
type: "Text",
|
||||
props: { text: "Generated from JSON", fontSize: 24, color: "#a0a0b0" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const svg = await renderToSvg(spec, {
|
||||
fonts: [
|
||||
{
|
||||
name: "Inter",
|
||||
data: await fetch("https://example.com/Inter-Regular.ttf").then((r) =>
|
||||
r.arrayBuffer()
|
||||
),
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Render to PNG
|
||||
|
||||
```typescript
|
||||
import { renderToPng } from "@json-render/image/render";
|
||||
|
||||
const png = await renderToPng(spec, {
|
||||
fonts: [
|
||||
{
|
||||
name: "Inter",
|
||||
data: await readFile("./Inter-Regular.ttf"),
|
||||
weight: 400,
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Write to file
|
||||
await writeFile("output.png", png);
|
||||
```
|
||||
|
||||
### With a custom catalog
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema, renderToSvg } from "@json-render/image";
|
||||
import { standardComponentDefinitions } from "@json-render/image/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",
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Standard Components
|
||||
|
||||
### Root
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Frame` | Root image container. Defines width, height, and background. Must be the root element. |
|
||||
|
||||
### Layout
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Box` | Generic container with padding, margin, background, border, and flex alignment. |
|
||||
| `Row` | Horizontal flex layout with gap, align, justify. |
|
||||
| `Column` | Vertical flex layout with gap, align, justify. |
|
||||
|
||||
### Content
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Heading` | h1-h4 heading text with color and alignment. |
|
||||
| `Text` | Body text with fontSize, color, weight, style, and alignment. |
|
||||
| `Image` | Image from a URL with width, height, and borderRadius. |
|
||||
|
||||
### Decorative
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Divider` | Horizontal line separator. |
|
||||
| `Spacer` | Empty vertical space. |
|
||||
|
||||
## Server-Side APIs
|
||||
|
||||
```typescript
|
||||
import { renderToSvg, renderToPng } from "@json-render/image/render";
|
||||
|
||||
// Render to an SVG string
|
||||
const svg = await renderToSvg(spec, { fonts });
|
||||
|
||||
// Render to a PNG buffer (requires @resvg/resvg-js)
|
||||
const png = await renderToPng(spec, { fonts });
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `fonts` | `SatoriOptions['fonts']` | `[]` | Font data for text rendering |
|
||||
| `width` | `number` | Frame prop | Override image width |
|
||||
| `height` | `number` | Frame prop | Override image height |
|
||||
| `registry` | `Record<string, Component>` | `{}` | Custom component overrides |
|
||||
| `includeStandard` | `boolean` | `true` | Include standard components |
|
||||
| `state` | `Record<string, unknown>` | `{}` | Initial state values |
|
||||
|
||||
## Server-Safe Import
|
||||
|
||||
Import schema and catalog definitions without pulling in React or Satori:
|
||||
|
||||
```typescript
|
||||
import { schema, standardComponentDefinitions } from "@json-render/image/server";
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"name": "@json-render/image",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"image",
|
||||
"svg",
|
||||
"png",
|
||||
"og",
|
||||
"satori",
|
||||
"ai",
|
||||
"generative-ui",
|
||||
"llm",
|
||||
"renderer"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/image"
|
||||
},
|
||||
"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:*",
|
||||
"satori": "^0.19.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@resvg/resvg-js": ">=2.0.0",
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@resvg/resvg-js": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type {
|
||||
Catalog,
|
||||
InferCatalogComponents,
|
||||
InferComponentProps,
|
||||
StateModel,
|
||||
} from "@json-render/core";
|
||||
|
||||
export type { StateModel };
|
||||
|
||||
export type SetState = (
|
||||
updater: (prev: Record<string, unknown>) => Record<string, unknown>,
|
||||
) => void;
|
||||
|
||||
export interface ComponentContext<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> {
|
||||
props: InferComponentProps<C, K>;
|
||||
children?: React.ReactNode;
|
||||
emit: (event: string) => void;
|
||||
}
|
||||
|
||||
export type ComponentFn<
|
||||
C extends Catalog,
|
||||
K extends keyof InferCatalogComponents<C>,
|
||||
> = (ctx: ComponentContext<C, K>) => React.ReactNode;
|
||||
|
||||
export type Components<C extends Catalog> = {
|
||||
[K in keyof InferCatalogComponents<C>]: ComponentFn<C, K>;
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Standard component definitions for image catalogs.
|
||||
*
|
||||
* These define the available image components with their Zod prop schemas.
|
||||
* All components render to Satori-compatible JSX (HTML-like elements with
|
||||
* inline CSS flexbox styles).
|
||||
*/
|
||||
export const standardComponentDefinitions = {
|
||||
// ==========================================================================
|
||||
// Root
|
||||
// ==========================================================================
|
||||
|
||||
Frame: {
|
||||
props: z.object({
|
||||
width: z.number(),
|
||||
height: z.number(),
|
||||
backgroundColor: z.string().nullable(),
|
||||
padding: z.number().nullable(),
|
||||
display: z.enum(["flex", "none"]).nullable(),
|
||||
flexDirection: z.enum(["row", "column"]).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:
|
||||
"Root image container. Defines the output image dimensions and background. Must be the root element.",
|
||||
example: { width: 1200, height: 630, backgroundColor: "#ffffff" },
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Layout Components
|
||||
// ==========================================================================
|
||||
|
||||
Box: {
|
||||
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(),
|
||||
width: z.union([z.number(), z.string()]).nullable(),
|
||||
height: z.union([z.number(), z.string()]).nullable(),
|
||||
alignItems: z
|
||||
.enum(["flex-start", "center", "flex-end", "stretch"])
|
||||
.nullable(),
|
||||
justifyContent: z
|
||||
.enum([
|
||||
"flex-start",
|
||||
"center",
|
||||
"flex-end",
|
||||
"space-between",
|
||||
"space-around",
|
||||
])
|
||||
.nullable(),
|
||||
flexDirection: z.enum(["row", "column"]).nullable(),
|
||||
position: z.enum(["relative", "absolute"]).nullable(),
|
||||
top: z.number().nullable(),
|
||||
left: z.number().nullable(),
|
||||
right: z.number().nullable(),
|
||||
bottom: z.number().nullable(),
|
||||
overflow: z.enum(["visible", "hidden"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Generic container with padding, margin, background, border, and flex alignment. Supports absolute positioning.",
|
||||
example: {
|
||||
padding: 20,
|
||||
backgroundColor: "#f9f9f9",
|
||||
borderRadius: 8,
|
||||
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(),
|
||||
letterSpacing: z.union([z.number(), z.string()]).nullable(),
|
||||
lineHeight: z.number().nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Heading text at various levels. h1 is largest, h4 is smallest.",
|
||||
example: { text: "Hello World", level: "h1", color: "#000000" },
|
||||
},
|
||||
|
||||
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(),
|
||||
letterSpacing: z.union([z.number(), z.string()]).nullable(),
|
||||
textDecoration: z.enum(["none", "underline", "line-through"]).nullable(),
|
||||
}),
|
||||
slots: [],
|
||||
description:
|
||||
"Body text with configurable size, color, weight, and alignment.",
|
||||
example: { text: "Some content here.", fontSize: 16, color: "#333333" },
|
||||
},
|
||||
|
||||
Image: {
|
||||
props: z.object({
|
||||
src: z.string(),
|
||||
width: z.number().nullable(),
|
||||
height: z.number().nullable(),
|
||||
borderRadius: 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 images use https://picsum.photos/{width}/{height}?random={n}.",
|
||||
example: {
|
||||
src: "https://picsum.photos/400/300?random=1",
|
||||
width: 400,
|
||||
height: 300,
|
||||
},
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// 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 },
|
||||
},
|
||||
};
|
||||
|
||||
export type StandardComponentDefinitions = typeof standardComponentDefinitions;
|
||||
|
||||
export type StandardComponentProps<
|
||||
K extends keyof StandardComponentDefinitions,
|
||||
> = StandardComponentDefinitions[K]["props"] extends { _output: infer O }
|
||||
? O
|
||||
: z.output<StandardComponentDefinitions[K]["props"]>;
|
||||
@@ -0,0 +1 @@
|
||||
export { standardComponents } from "./standard";
|
||||
@@ -0,0 +1,265 @@
|
||||
import React from "react";
|
||||
import type { ComponentRenderProps, ComponentRegistry } from "../types";
|
||||
import type { StandardComponentProps } from "../catalog";
|
||||
|
||||
const headingSizes: Record<string, number> = {
|
||||
h1: 48,
|
||||
h2: 36,
|
||||
h3: 28,
|
||||
h4: 22,
|
||||
};
|
||||
|
||||
const headingWeights: Record<string, number> = {
|
||||
h1: 700,
|
||||
h2: 700,
|
||||
h3: 600,
|
||||
h4: 600,
|
||||
};
|
||||
|
||||
/**
|
||||
* Satori crashes on explicit `undefined` style values (e.g. `padding: undefined`).
|
||||
* Strip them so only defined properties are passed.
|
||||
*/
|
||||
function cleanStyle(raw: Record<string, unknown>): React.CSSProperties {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const k in raw) {
|
||||
if (raw[k] !== undefined && raw[k] !== null) {
|
||||
out[k] = raw[k];
|
||||
}
|
||||
}
|
||||
return out as React.CSSProperties;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Root
|
||||
// =============================================================================
|
||||
|
||||
function FrameComponent({
|
||||
element,
|
||||
children,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Frame">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: p.display ?? "flex",
|
||||
flexDirection: p.flexDirection ?? "column",
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
backgroundColor: p.backgroundColor ?? "white",
|
||||
padding: p.padding,
|
||||
alignItems: p.alignItems,
|
||||
justifyContent: p.justifyContent,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Layout Components
|
||||
// =============================================================================
|
||||
|
||||
function BoxComponent({
|
||||
element,
|
||||
children,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Box">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: "flex",
|
||||
flexDirection: p.flexDirection ?? "column",
|
||||
padding: p.padding,
|
||||
paddingTop: p.paddingTop,
|
||||
paddingBottom: p.paddingBottom,
|
||||
paddingLeft: p.paddingLeft,
|
||||
paddingRight: p.paddingRight,
|
||||
margin: p.margin,
|
||||
backgroundColor: p.backgroundColor,
|
||||
borderWidth: p.borderWidth,
|
||||
borderColor: p.borderColor,
|
||||
borderRadius: p.borderRadius,
|
||||
borderStyle: p.borderWidth ? "solid" : undefined,
|
||||
flex: p.flex,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
alignItems: p.alignItems,
|
||||
justifyContent: p.justifyContent,
|
||||
position: p.position,
|
||||
top: p.top,
|
||||
left: p.left,
|
||||
right: p.right,
|
||||
bottom: p.bottom,
|
||||
overflow: p.overflow,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowComponent({
|
||||
element,
|
||||
children,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Row">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
gap: p.gap,
|
||||
alignItems: p.alignItems,
|
||||
justifyContent: p.justifyContent,
|
||||
padding: p.padding,
|
||||
flex: p.flex,
|
||||
flexWrap: p.wrap ? "wrap" : undefined,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnComponent({
|
||||
element,
|
||||
children,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Column">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: p.gap,
|
||||
alignItems: p.alignItems,
|
||||
justifyContent: p.justifyContent,
|
||||
padding: p.padding,
|
||||
flex: p.flex,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Content Components
|
||||
// =============================================================================
|
||||
|
||||
function HeadingComponent({
|
||||
element,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Heading">>) {
|
||||
const p = element.props;
|
||||
const level = p.level ?? "h2";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: "flex",
|
||||
fontSize: headingSizes[level] ?? 36,
|
||||
fontWeight: headingWeights[level] ?? 700,
|
||||
color: p.color ?? "black",
|
||||
textAlign: p.align ?? "left",
|
||||
letterSpacing: p.letterSpacing,
|
||||
lineHeight: p.lineHeight ?? 1.2,
|
||||
})}
|
||||
>
|
||||
{p.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextComponent({
|
||||
element,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Text">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={cleanStyle({
|
||||
display: "flex",
|
||||
fontSize: p.fontSize ?? 16,
|
||||
color: p.color ?? "black",
|
||||
textAlign: p.align ?? "left",
|
||||
fontWeight: p.fontWeight === "bold" ? 700 : 400,
|
||||
fontStyle: p.fontStyle ?? "normal",
|
||||
lineHeight: p.lineHeight ?? 1.4,
|
||||
letterSpacing: p.letterSpacing,
|
||||
textDecoration: p.textDecoration,
|
||||
})}
|
||||
>
|
||||
{p.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageComponent({
|
||||
element,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Image">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<img
|
||||
src={p.src}
|
||||
width={p.width ?? undefined}
|
||||
height={p.height ?? undefined}
|
||||
style={cleanStyle({
|
||||
borderRadius: p.borderRadius,
|
||||
objectFit: p.objectFit ?? "contain",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Decorative Components
|
||||
// =============================================================================
|
||||
|
||||
function DividerComponent({
|
||||
element,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Divider">>) {
|
||||
const p = element.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100%",
|
||||
borderBottom: `${p.thickness ?? 1}px solid ${p.color ?? "#e5e7eb"}`,
|
||||
marginTop: p.marginTop ?? 8,
|
||||
marginBottom: p.marginBottom ?? 8,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SpacerComponent({
|
||||
element,
|
||||
}: ComponentRenderProps<StandardComponentProps<"Spacer">>) {
|
||||
const p = element.props;
|
||||
|
||||
return <div style={{ display: "flex", height: p.height ?? 20 }} />;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registry
|
||||
// =============================================================================
|
||||
|
||||
export const standardComponents: ComponentRegistry = {
|
||||
Frame: FrameComponent,
|
||||
Box: BoxComponent,
|
||||
Row: RowComponent,
|
||||
Column: ColumnComponent,
|
||||
Heading: HeadingComponent,
|
||||
Text: TextComponent,
|
||||
Image: ImageComponent,
|
||||
Divider: DividerComponent,
|
||||
Spacer: SpacerComponent,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
// Schema
|
||||
export { schema, type ImageSchema, type ImageSpec } 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";
|
||||
|
||||
// Renderer types
|
||||
export type {
|
||||
ComponentRenderProps,
|
||||
ComponentRenderer,
|
||||
ComponentRegistry,
|
||||
} from "./types";
|
||||
|
||||
// Standard components
|
||||
export { standardComponents } from "./components";
|
||||
|
||||
// Server-side render functions
|
||||
export { renderToSvg, renderToPng, type RenderOptions } from "./render";
|
||||
|
||||
// Catalog definitions
|
||||
export {
|
||||
standardComponentDefinitions,
|
||||
type StandardComponentDefinitions,
|
||||
type StandardComponentProps,
|
||||
} from "./catalog";
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { renderToSvg, renderToPng } from "./render";
|
||||
|
||||
const minimalSpec: Spec = {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: {
|
||||
width: 400,
|
||||
height: 200,
|
||||
backgroundColor: "#ffffff",
|
||||
},
|
||||
children: ["box"],
|
||||
},
|
||||
box: {
|
||||
type: "Box",
|
||||
props: {
|
||||
width: 100,
|
||||
height: 50,
|
||||
backgroundColor: "#ff0000",
|
||||
borderRadius: 8,
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("renderToSvg", () => {
|
||||
it("produces a valid SVG string", async () => {
|
||||
const svg = await renderToSvg(minimalSpec);
|
||||
expect(svg).toContain("<svg");
|
||||
expect(svg).toContain("</svg>");
|
||||
});
|
||||
|
||||
it("respects width/height from Frame props", async () => {
|
||||
const svg = await renderToSvg(minimalSpec);
|
||||
expect(svg).toContain('width="400"');
|
||||
expect(svg).toContain('height="200"');
|
||||
});
|
||||
|
||||
it("uses explicit width/height options over Frame props", async () => {
|
||||
const svg = await renderToSvg(minimalSpec, { width: 800, height: 600 });
|
||||
expect(svg).toContain('width="800"');
|
||||
expect(svg).toContain('height="600"');
|
||||
});
|
||||
|
||||
it("falls back to defaults when Frame has no dimensions", async () => {
|
||||
const spec: Spec = {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: { backgroundColor: "#000" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const svg = await renderToSvg(spec);
|
||||
expect(svg).toContain('width="1200"');
|
||||
expect(svg).toContain('height="630"');
|
||||
});
|
||||
|
||||
it("gracefully skips unknown component types", async () => {
|
||||
const spec: Spec = {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: { width: 400, height: 200 },
|
||||
children: ["unknown"],
|
||||
},
|
||||
unknown: {
|
||||
type: "NonExistent",
|
||||
props: {},
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const svg = await renderToSvg(spec);
|
||||
expect(svg).toContain("<svg");
|
||||
});
|
||||
|
||||
it("renders without standard components when includeStandard is false", async () => {
|
||||
const spec: Spec = {
|
||||
root: "frame",
|
||||
elements: {
|
||||
frame: {
|
||||
type: "Frame",
|
||||
props: { width: 400, height: 200 },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
const svg = await renderToSvg(spec, { includeStandard: false });
|
||||
expect(svg).toContain("<svg");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderToPng", () => {
|
||||
it("produces a buffer with content", async () => {
|
||||
const png = await renderToPng(minimalSpec);
|
||||
expect(png.length).toBeGreaterThan(0);
|
||||
expect(png.byteLength).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("starts with PNG magic bytes", async () => {
|
||||
const png = await renderToPng(minimalSpec);
|
||||
expect(png[0]).toBe(0x89);
|
||||
expect(png[1]).toBe(0x50); // P
|
||||
expect(png[2]).toBe(0x4e); // N
|
||||
expect(png[3]).toBe(0x47); // G
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
import React from "react";
|
||||
import satori, { type SatoriOptions } from "satori";
|
||||
import type { Spec, UIElement } from "@json-render/core";
|
||||
import {
|
||||
resolveElementProps,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
type PropResolutionContext,
|
||||
} from "@json-render/core";
|
||||
import { standardComponents } from "./components/standard";
|
||||
import type { ComponentRegistry } from "./types";
|
||||
|
||||
export { standardComponents };
|
||||
|
||||
export interface RenderOptions {
|
||||
registry?: ComponentRegistry;
|
||||
includeStandard?: boolean;
|
||||
state?: Record<string, unknown>;
|
||||
fonts?: SatoriOptions["fonts"];
|
||||
/** Override the Frame width. When omitted, uses the Frame component's width prop. */
|
||||
width?: number;
|
||||
/** Override the Frame height. When omitted, uses the Frame component's height prop. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
const noopEmit = () => {};
|
||||
|
||||
function renderElement(
|
||||
elementKey: string,
|
||||
spec: Spec,
|
||||
registry: ComponentRegistry,
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImageDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function getDimensions(
|
||||
spec: Spec,
|
||||
options: RenderOptions = {},
|
||||
): ImageDimensions {
|
||||
if (options.width && options.height) {
|
||||
return { width: options.width, height: options.height };
|
||||
}
|
||||
|
||||
const rootElement = spec.elements[spec.root];
|
||||
const props = rootElement?.props as Record<string, unknown> | undefined;
|
||||
|
||||
return {
|
||||
width: options.width ?? (props?.width as number) ?? 1200,
|
||||
height: options.height ?? (props?.height as number) ?? 630,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTree(
|
||||
spec: Spec,
|
||||
options: RenderOptions = {},
|
||||
): React.ReactElement {
|
||||
const {
|
||||
registry: customRegistry,
|
||||
includeStandard = true,
|
||||
state = {},
|
||||
} = options;
|
||||
|
||||
const mergedState: Record<string, unknown> = {
|
||||
...spec.state,
|
||||
...state,
|
||||
};
|
||||
|
||||
const registry: ComponentRegistry = {
|
||||
...(includeStandard ? standardComponents : {}),
|
||||
...customRegistry,
|
||||
};
|
||||
|
||||
const root = renderElement(spec.root, spec, registry, mergedState);
|
||||
return root ?? <></>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a json-render spec to an SVG string.
|
||||
*
|
||||
* Uses Satori to convert the spec's component tree into SVG.
|
||||
* No additional dependencies are needed beyond satori.
|
||||
*/
|
||||
export async function renderToSvg(
|
||||
spec: Spec,
|
||||
options: RenderOptions = {},
|
||||
): Promise<string> {
|
||||
const tree = buildTree(spec, options);
|
||||
const { width, height } = getDimensions(spec, options);
|
||||
|
||||
return satori(tree as React.ReactNode, {
|
||||
width,
|
||||
height,
|
||||
fonts: options.fonts ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a json-render spec to a PNG buffer.
|
||||
*
|
||||
* Requires `@resvg/resvg-js` to be installed as a peer dependency.
|
||||
* The SVG is first generated via Satori, then rasterized to PNG.
|
||||
*/
|
||||
export async function renderToPng(
|
||||
spec: Spec,
|
||||
options: RenderOptions = {},
|
||||
): Promise<Uint8Array> {
|
||||
const svg = await renderToSvg(spec, options);
|
||||
|
||||
let Resvg: typeof import("@resvg/resvg-js").Resvg;
|
||||
try {
|
||||
const mod = await import("@resvg/resvg-js");
|
||||
Resvg = mod.Resvg;
|
||||
} catch {
|
||||
throw new Error(
|
||||
"@resvg/resvg-js is required for PNG output. Install it with: npm install @resvg/resvg-js",
|
||||
);
|
||||
}
|
||||
|
||||
const resvg = new Resvg(svg);
|
||||
const pngData = resvg.render();
|
||||
return pngData.asPng();
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { defineSchema } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* The schema for @json-render/image
|
||||
*
|
||||
* 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 PDF 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 Frame component. It defines the image dimensions (width, height) and background.",
|
||||
"Frame width and height determine the output image size. Common sizes: 1200x630 (OG image), 1080x1080 (social square), 1920x1080 (banner).",
|
||||
"Use Row for horizontal layouts and Column for vertical layouts. Both support gap, align, and justify props.",
|
||||
"All text content must use Heading or Text components. Raw strings are not supported.",
|
||||
"Image src must be a fully qualified URL. For placeholder images, use https://picsum.photos/{width}/{height}?random={n}.",
|
||||
"Satori renders a subset of CSS: flexbox layout, borders, backgrounds, text styling. Absolute positioning is supported via position/top/left/right/bottom.",
|
||||
"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 ImageSchema = typeof schema;
|
||||
|
||||
export type ImageSpec<TCatalog> = typeof schema extends {
|
||||
createCatalog: (catalog: TCatalog) => { _specType: infer S };
|
||||
}
|
||||
? S
|
||||
: never;
|
||||
@@ -0,0 +1,20 @@
|
||||
// Server-safe entry point: schema and catalog definitions only.
|
||||
// Does not import React or Satori.
|
||||
|
||||
export { schema, type ImageSchema, type ImageSpec } from "./schema";
|
||||
|
||||
export {
|
||||
standardComponentDefinitions,
|
||||
type StandardComponentDefinitions,
|
||||
type StandardComponentProps,
|
||||
} from "./catalog";
|
||||
|
||||
export type { Spec } from "@json-render/core";
|
||||
|
||||
export type {
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
} from "./catalog-types";
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import type { UIElement } from "@json-render/core";
|
||||
|
||||
export interface ComponentRenderProps<P = Record<string, unknown>> {
|
||||
element: UIElement<string, P>;
|
||||
children?: ReactNode;
|
||||
emit: (event: string) => void;
|
||||
}
|
||||
|
||||
export type ComponentRenderer<P = Record<string, unknown>> = ComponentType<
|
||||
ComponentRenderProps<P>
|
||||
>;
|
||||
|
||||
export type ComponentRegistry = Record<string, ComponentRenderer<any>>;
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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: [
|
||||
"@json-render/core",
|
||||
"satori",
|
||||
"@resvg/resvg-js",
|
||||
"zod",
|
||||
"react",
|
||||
"react/jsx-runtime",
|
||||
],
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/jotai
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/jotai",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Jotai adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-native
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-native",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
|
||||
"keywords": [
|
||||
@@ -65,7 +65,7 @@
|
||||
"react-native": "0.83.1",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react-pdf
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react-pdf",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
|
||||
"keywords": [
|
||||
@@ -69,7 +69,7 @@
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @internal/react-state
|
||||
|
||||
## 0.8.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.8.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@internal/react-state",
|
||||
"version": "0.8.3",
|
||||
"version": "0.8.4",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"description": "Shared React state context for json-render renderer packages",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/react
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/react",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "React renderer for @json-render/core. JSON becomes React components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/redux
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/redux",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Redux adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/remotion
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/remotion",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
|
||||
"keywords": [
|
||||
@@ -59,7 +59,7 @@
|
||||
"remotion": "4.0.418",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.3",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @json-render/shadcn
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
- @json-render/react@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/shadcn",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
|
||||
"keywords": [
|
||||
@@ -69,7 +69,7 @@
|
||||
"@types/react": "19.2.3",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5",
|
||||
"zod": "^4.0.0"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/vue
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/vue",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/xstate
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/xstate",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "XState Store adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @json-render/zustand
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [3f1e71e]
|
||||
- @json-render/core@0.11.0
|
||||
|
||||
## 0.10.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@json-render/zustand",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Zustand adapter for json-render StateStore",
|
||||
"keywords": [
|
||||
|
||||
Generated
+568
-130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
---
|
||||
name: json-render-image
|
||||
description: Image renderer for json-render that turns JSON specs into SVG and PNG images via Satori. Use when working with @json-render/image, generating OG images from JSON, creating social cards, or rendering AI-generated image specs.
|
||||
---
|
||||
|
||||
# @json-render/image
|
||||
|
||||
Image renderer that converts JSON specs into SVG and PNG images using Satori.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema, standardComponentDefinitions } from "@json-render/image";
|
||||
|
||||
export const imageCatalog = defineCatalog(schema, {
|
||||
components: standardComponentDefinitions,
|
||||
});
|
||||
```
|
||||
|
||||
## Adding Custom Components
|
||||
|
||||
```typescript
|
||||
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 |
|
||||
@@ -16,7 +16,7 @@
|
||||
"jotai": "^2.18.0",
|
||||
"redux": "^5.0.1",
|
||||
"vitest": "^4.0.17",
|
||||
"zod": "^4.3.5",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user