Просмотр исходного кода

image (#173)

* image

* fixes

* fixes

* fix ci

* fix ci

* disable @next/next/no-img-element rule in ESLint configuration

* update route.ts to use path.join for font resolution and adjust next-env.d.ts import path
Chris Tate 4 месяцев назад
Родитель
Сommit
553c803422
48 измененных файлов с 3731 добавлено и 130 удалено
  1. 1 1
      apps/web/package.json
  2. 1 1
      examples/chat/package.json
  3. 1 1
      examples/dashboard/package.json
  4. 34 0
      examples/image/app/api/generate/route.ts
  5. 79 0
      examples/image/app/api/image/route.ts
  6. 124 0
      examples/image/app/globals.css
  7. 22 0
      examples/image/app/layout.tsx
  8. 627 0
      examples/image/app/page.tsx
  9. 23 0
      examples/image/components.json
  10. 53 0
      examples/image/components/ui/resizable.tsx
  11. 58 0
      examples/image/components/ui/scroll-area.tsx
  12. 143 0
      examples/image/components/ui/sheet.tsx
  13. 12 0
      examples/image/eslint.config.js
  14. 7 0
      examples/image/lib/catalog.ts
  15. 771 0
      examples/image/lib/examples.ts
  16. 6 0
      examples/image/lib/utils.ts
  17. 6 0
      examples/image/next-env.d.ts
  18. 7 0
      examples/image/next.config.ts
  19. 44 0
      examples/image/package.json
  20. 8 0
      examples/image/postcss.config.mjs
  21. 13 0
      examples/image/tsconfig.json
  22. 1 1
      examples/no-ai/package.json
  23. 1 1
      examples/react-native/package.json
  24. 1 1
      examples/react-pdf/package.json
  25. 1 1
      examples/remotion/package.json
  26. 1 1
      examples/stripe-app/drawer-app/package.json
  27. 1 1
      examples/stripe-app/fullpage-app/package.json
  28. 1 1
      packages/core/package.json
  29. 167 0
      packages/image/README.md
  30. 85 0
      packages/image/package.json
  31. 30 0
      packages/image/src/catalog-types.ts
  32. 228 0
      packages/image/src/catalog.ts
  33. 1 0
      packages/image/src/components/index.ts
  34. 265 0
      packages/image/src/components/standard.tsx
  35. 34 0
      packages/image/src/index.ts
  36. 115 0
      packages/image/src/render.test.tsx
  37. 210 0
      packages/image/src/render.tsx
  38. 54 0
      packages/image/src/schema.ts
  39. 20 0
      packages/image/src/server.ts
  40. 14 0
      packages/image/src/types.ts
  41. 9 0
      packages/image/tsconfig.json
  42. 17 0
      packages/image/tsup.config.ts
  43. 1 1
      packages/react-native/package.json
  44. 1 1
      packages/react-pdf/package.json
  45. 1 1
      packages/remotion/package.json
  46. 1 1
      packages/shadcn/package.json
  47. 430 115
      pnpm-lock.yaml
  48. 1 1
      tests/e2e/package.json

+ 1 - 1
apps/web/package.json

@@ -50,7 +50,7 @@
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
     "unist-util-visit": "5.1.0",
     "unist-util-visit": "5.1.0",
     "vaul": "^1.1.2",
     "vaul": "^1.1.2",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/eslint-config": "workspace:*",
     "@internal/eslint-config": "workspace:*",

+ 1 - 1
examples/chat/package.json

@@ -33,7 +33,7 @@
     "streamdown": "^2.2.0",
     "streamdown": "^2.2.0",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
     "three": "^0.182.0",
     "three": "^0.182.0",
-    "zod": "4.3.5"
+    "zod": "4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/eslint-config": "workspace:*",
     "@internal/eslint-config": "workspace:*",

+ 1 - 1
examples/dashboard/package.json

@@ -39,7 +39,7 @@
     "sonner": "^2.0.7",
     "sonner": "^2.0.7",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
     "vaul": "^1.1.2",
     "vaul": "^1.1.2",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/eslint-config": "workspace:*",
     "@internal/eslint-config": "workspace:*",

+ 34 - 0
examples/image/app/api/generate/route.ts

@@ -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();
+}

+ 79 - 0
examples/image/app/api/image/route.ts

@@ -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",
+    },
+  });
+}

+ 124 - 0
examples/image/app/globals.css

@@ -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;
+  }
+}

+ 22 - 0
examples/image/app/layout.tsx

@@ -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>
+  );
+}

+ 627 - 0
examples/image/app/page.tsx

@@ -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>
+  );
+}

+ 23 - 0
examples/image/components.json

@@ -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": {}
+}

+ 53 - 0
examples/image/components/ui/resizable.tsx

@@ -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 };

+ 58 - 0
examples/image/components/ui/scroll-area.tsx

@@ -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 };

+ 143 - 0
examples/image/components/ui/sheet.tsx

@@ -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,
+};

+ 12 - 0
examples/image/eslint.config.js

@@ -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",
+    },
+  },
+];

+ 7 - 0
examples/image/lib/catalog.ts

@@ -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,
+});

+ 771 - 0
examples/image/lib/examples.ts

@@ -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: [],
+        },
+      },
+    },
+  },
+];

+ 6 - 0
examples/image/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 6 - 0
examples/image/next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+import "./.next/types/routes.d.ts";
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 7 - 0
examples/image/next.config.ts

@@ -0,0 +1,7 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  serverExternalPackages: ["@resvg/resvg-js", "satori"],
+};
+
+export default nextConfig;

+ 44 - 0
examples/image/package.json

@@ -0,0 +1,44 @@
+{
+  "name": "example-image",
+  "version": "0.1.0",
+  "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"
+  }
+}

+ 8 - 0
examples/image/postcss.config.mjs

@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;

+ 13 - 0
examples/image/tsconfig.json

@@ -0,0 +1,13 @@
+{
+  "extends": "../../packages/typescript-config/nextjs.json",
+  "compilerOptions": {
+    "plugins": [{ "name": "next" }],
+    "declaration": false,
+    "declarationMap": false,
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}

+ 1 - 1
examples/no-ai/package.json

@@ -23,7 +23,7 @@
     "react-dom": "19.2.4",
     "react-dom": "19.2.4",
     "react-confetti-explosion": "^3.0.3",
     "react-confetti-explosion": "^3.0.3",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
-    "zod": "4.3.5"
+    "zod": "4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/eslint-config": "workspace:*",
     "@internal/eslint-config": "workspace:*",

+ 1 - 1
examples/react-native/package.json

@@ -25,7 +25,7 @@
     "react-native": "0.81.4",
     "react-native": "0.81.4",
     "react-native-safe-area-context": "~5.4.0",
     "react-native-safe-area-context": "~5.4.0",
     "react-native-screens": "~4.11.1",
     "react-native-screens": "~4.11.1",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@types/node": "^22.10.0",
     "@types/node": "^22.10.0",

+ 1 - 1
examples/react-pdf/package.json

@@ -25,7 +25,7 @@
     "react-resizable-panels": "^4.4.1",
     "react-resizable-panels": "^4.4.1",
     "tailwind-merge": "^3.5.0",
     "tailwind-merge": "^3.5.0",
     "tailwindcss": "^4.1.18",
     "tailwindcss": "^4.1.18",
-    "zod": "4.3.5"
+    "zod": "4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/typescript-config": "workspace:*",
     "@internal/typescript-config": "workspace:*",

+ 1 - 1
examples/remotion/package.json

@@ -26,7 +26,7 @@
     "remotion": "4.0.418",
     "remotion": "4.0.418",
     "shiki": "^3.21.0",
     "shiki": "^3.21.0",
     "tailwind-merge": "^3.4.0",
     "tailwind-merge": "^3.4.0",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/eslint-config": "workspace:*",
     "@internal/eslint-config": "workspace:*",

+ 1 - 1
examples/stripe-app/drawer-app/package.json

@@ -10,7 +10,7 @@
     "@json-render/react": "workspace:*",
     "@json-render/react": "workspace:*",
     "@stripe/ui-extension-sdk": "^9.1.0",
     "@stripe/ui-extension-sdk": "^9.1.0",
     "stripe": "^13.11.0",
     "stripe": "^13.11.0",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "engines": {
   "engines": {
     "node": ">=14"
     "node": ">=14"

+ 1 - 1
examples/stripe-app/fullpage-app/package.json

@@ -10,7 +10,7 @@
     "@json-render/react": "workspace:*",
     "@json-render/react": "workspace:*",
     "@stripe/ui-extension-sdk": "9.2.0-alpha.0",
     "@stripe/ui-extension-sdk": "9.2.0-alpha.0",
     "stripe": "^13.11.0",
     "stripe": "^13.11.0",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "engines": {
   "engines": {
     "node": ">=14"
     "node": ">=14"

+ 1 - 1
packages/core/package.json

@@ -50,7 +50,7 @@
     "typecheck": "tsc --noEmit"
     "typecheck": "tsc --noEmit"
   },
   },
   "dependencies": {
   "dependencies": {
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@internal/typescript-config": "workspace:*",
     "@internal/typescript-config": "workspace:*",

+ 167 - 0
packages/image/README.md

@@ -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

+ 85 - 0
packages/image/package.json

@@ -0,0 +1,85 @@
+{
+  "name": "@json-render/image",
+  "version": "0.1.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
+    }
+  }
+}

+ 30 - 0
packages/image/src/catalog-types.ts

@@ -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>;
+};

+ 228 - 0
packages/image/src/catalog.ts

@@ -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"]>;

+ 1 - 0
packages/image/src/components/index.ts

@@ -0,0 +1 @@
+export { standardComponents } from "./standard";

+ 265 - 0
packages/image/src/components/standard.tsx

@@ -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,
+};

+ 34 - 0
packages/image/src/index.ts

@@ -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";

+ 115 - 0
packages/image/src/render.test.tsx

@@ -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
+  });
+});

+ 210 - 0
packages/image/src/render.tsx

@@ -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();
+}

+ 54 - 0
packages/image/src/schema.ts

@@ -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;

+ 20 - 0
packages/image/src/server.ts

@@ -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";

+ 14 - 0
packages/image/src/types.ts

@@ -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>>;

+ 9 - 0
packages/image/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 17 - 0
packages/image/tsup.config.ts

@@ -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 - 1
packages/react-native/package.json

@@ -65,7 +65,7 @@
     "react-native": "0.83.1",
     "react-native": "0.83.1",
     "tsup": "^8.0.2",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",
     "typescript": "^5.4.5",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "react": "^18.0.0 || ^19.0.0",
     "react": "^18.0.0 || ^19.0.0",

+ 1 - 1
packages/react-pdf/package.json

@@ -69,7 +69,7 @@
     "@types/react": "19.2.3",
     "@types/react": "19.2.3",
     "tsup": "^8.0.2",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",
     "typescript": "^5.4.5",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "react": "^18.0.0 || ^19.0.0",
     "react": "^18.0.0 || ^19.0.0",

+ 1 - 1
packages/remotion/package.json

@@ -59,7 +59,7 @@
     "remotion": "4.0.418",
     "remotion": "4.0.418",
     "tsup": "^8.0.2",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",
     "typescript": "^5.4.5",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "react": "^19.2.3",
     "react": "^19.2.3",

+ 1 - 1
packages/shadcn/package.json

@@ -69,7 +69,7 @@
     "@types/react": "19.2.3",
     "@types/react": "19.2.3",
     "tsup": "^8.0.2",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",
     "typescript": "^5.4.5",
-    "zod": "^4.0.0"
+    "zod": "^4.3.6"
   },
   },
   "peerDependencies": {
   "peerDependencies": {
     "react": "^19.0.0",
     "react": "^19.0.0",

Разница между файлами не показана из-за своего большого размера
+ 430 - 115
pnpm-lock.yaml


+ 1 - 1
tests/e2e/package.json

@@ -16,7 +16,7 @@
     "jotai": "^2.18.0",
     "jotai": "^2.18.0",
     "redux": "^5.0.1",
     "redux": "^5.0.1",
     "vitest": "^4.0.17",
     "vitest": "^4.0.17",
-    "zod": "^4.3.5",
+    "zod": "^4.3.6",
     "zustand": "^5.0.11"
     "zustand": "^5.0.11"
   }
   }
 }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов