mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a99d68c481 | ||
|
|
40892a6af0 |
@@ -23,7 +23,9 @@
|
||||
"@json-render/solid",
|
||||
"@json-render/react-three-fiber",
|
||||
"@json-render/yaml",
|
||||
"@json-render/ink"
|
||||
"@json-render/shadcn-svelte",
|
||||
"@json-render/ink",
|
||||
"@json-render/next"
|
||||
]
|
||||
],
|
||||
"linked": [],
|
||||
|
||||
@@ -25,6 +25,8 @@ npm install @json-render/core @json-render/svelte
|
||||
npm install @json-render/core @json-render/solid
|
||||
# or for terminal UIs
|
||||
npm install @json-render/core @json-render/ink ink react
|
||||
# or for full Next.js apps (routes, layouts, SSR, metadata)
|
||||
npm install @json-render/core @json-render/react @json-render/next
|
||||
# or for 3D scenes
|
||||
npm install @json-render/core @json-render/react-three-fiber @react-three/fiber @react-three/drei three
|
||||
```
|
||||
@@ -127,6 +129,7 @@ function Dashboard({ spec }) {
|
||||
| `@json-render/shadcn` | 36 pre-built shadcn/ui components (Radix UI + Tailwind CSS) |
|
||||
| `@json-render/react-three-fiber` | React Three Fiber renderer for 3D scenes (19 built-in components) |
|
||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||
| `@json-render/next` | Next.js renderer — JSON becomes full apps with routes, layouts, SSR |
|
||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||
| `@json-render/react-email` | React Email renderer for HTML/plain-text emails from specs |
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { pageMetadata } from "@/lib/page-metadata"
|
||||
export const metadata = pageMetadata("docs/api/next")
|
||||
|
||||
# @json-render/next
|
||||
|
||||
Next.js renderer. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react @json-render/next
|
||||
```
|
||||
|
||||
## schema
|
||||
|
||||
The Next.js app schema for multi-page specs. Use with `defineCatalog` from core.
|
||||
|
||||
```typescript
|
||||
import { defineCatalog } from '@json-render/core';
|
||||
import { schema } from '@json-render/next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
description: 'Card container',
|
||||
},
|
||||
NavBar: {
|
||||
props: z.object({ links: z.array(z.object({ href: z.string(), label: z.string() })) }),
|
||||
description: 'Navigation bar',
|
||||
},
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
## createNextApp
|
||||
|
||||
Create all exports needed for a Next.js `[[...slug]]` catch-all route.
|
||||
|
||||
```typescript
|
||||
import { createNextApp } from '@json-render/next/server';
|
||||
|
||||
const { Page, generateMetadata, generateStaticParams } = createNextApp({
|
||||
spec: myAppSpec,
|
||||
loaders: {
|
||||
loadPost: async ({ slug }) => {
|
||||
const post = await db.post.findUnique({ where: { slug } });
|
||||
return { post };
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Option</th>
|
||||
<th>Type</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>spec</code></td>
|
||||
<td><code>{'NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>)'}</code></td>
|
||||
<td>The application spec (static or dynamic)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>loaders</code></td>
|
||||
<td><code>{'Record<string, LoaderFn>'}</code></td>
|
||||
<td>Server-side data loaders keyed by name</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
### Returns
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Export</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>Page</code></td>
|
||||
<td>Async Server Component for <code>page.tsx</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>generateMetadata</code></td>
|
||||
<td>Metadata generator for Next.js SEO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>generateStaticParams</code></td>
|
||||
<td>Static params for pre-rendering at build time</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## NextAppSpec
|
||||
|
||||
The top-level spec defining an entire Next.js application.
|
||||
|
||||
```typescript
|
||||
interface NextAppSpec {
|
||||
metadata?: NextMetadata;
|
||||
routes: Record<string, NextRouteSpec>;
|
||||
layouts?: Record<string, Spec>;
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### Route Patterns
|
||||
|
||||
Routes use Next.js URL conventions:
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pattern</th>
|
||||
<th>Example Match</th>
|
||||
<th>Params</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>/</code></td>
|
||||
<td><code>/</code></td>
|
||||
<td><code>{'{}'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/about</code></td>
|
||||
<td><code>/about</code></td>
|
||||
<td><code>{'{}'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/blog/[slug]</code></td>
|
||||
<td><code>/blog/hello</code></td>
|
||||
<td><code>{'{ slug: "hello" }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/docs/[...path]</code></td>
|
||||
<td><code>/docs/a/b/c</code></td>
|
||||
<td><code>{'{ path: ["a","b","c"] }'}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>/app/[[...path]]</code></td>
|
||||
<td><code>/app</code> or <code>/app/x/y</code></td>
|
||||
<td><code>{'{ path: [] }'}</code> or <code>{'{ path: ["x","y"] }'}</code></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## NextAppProvider
|
||||
|
||||
Client component that provides the component registry and action handlers to all pages.
|
||||
|
||||
```tsx
|
||||
import { NextAppProvider } from '@json-render/next';
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<NextAppProvider registry={registry} handlers={handlers}>
|
||||
{children}
|
||||
</NextAppProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Components
|
||||
|
||||
### Slot
|
||||
|
||||
Placeholder in layouts where page content is rendered. Every layout MUST include a Slot.
|
||||
|
||||
```json
|
||||
{ "type": "Slot", "props": {}, "children": [] }
|
||||
```
|
||||
|
||||
### Link
|
||||
|
||||
Client-side navigation wrapping `next/link`.
|
||||
|
||||
```json
|
||||
{ "type": "Link", "props": { "href": "/about" }, "children": ["link-text"] }
|
||||
```
|
||||
|
||||
## Built-in Actions
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Action</th>
|
||||
<th>Params</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>setState</code></td>
|
||||
<td><code>{'{ statePath, value }'}</code></td>
|
||||
<td>Update a value in state</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>pushState</code></td>
|
||||
<td><code>{'{ statePath, value, clearStatePath? }'}</code></td>
|
||||
<td>Append to array in state</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>removeState</code></td>
|
||||
<td><code>{'{ statePath, index }'}</code></td>
|
||||
<td>Remove from array by index</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>navigate</code></td>
|
||||
<td><code>{'{ href }'}</code></td>
|
||||
<td>Client-side navigation</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
## Server Utilities
|
||||
|
||||
### matchRoute
|
||||
|
||||
Match a pathname against a spec's routes.
|
||||
|
||||
```typescript
|
||||
import { matchRoute } from '@json-render/next/server';
|
||||
|
||||
const matched = matchRoute(spec, '/blog/hello-world');
|
||||
// { route: NextRouteSpec, pattern: '/blog/[slug]', params: { slug: 'hello-world' } }
|
||||
```
|
||||
|
||||
### resolveMetadata
|
||||
|
||||
Resolve merged metadata for a route.
|
||||
|
||||
```typescript
|
||||
import { resolveMetadata } from '@json-render/next/server';
|
||||
|
||||
const metadata = resolveMetadata(spec, matchedRoute?.route);
|
||||
```
|
||||
|
||||
### slugToPath
|
||||
|
||||
Convert catch-all slug array to pathname.
|
||||
|
||||
```typescript
|
||||
import { slugToPath } from '@json-render/next/server';
|
||||
|
||||
slugToPath(undefined); // "/"
|
||||
slugToPath(['blog', 'hello']); // "/blog/hello"
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Import</th>
|
||||
<th>Contents</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>@json-render/next</code></td>
|
||||
<td>Client components (NextAppProvider, PageRenderer, Link)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>@json-render/next/server</code></td>
|
||||
<td>Server utilities (createNextApp, matchRoute, schema)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
|
||||
|
||||
GitHub repository: https://github.com/vercel-labs/json-render
|
||||
Documentation: https://json-render.dev/docs
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, ink, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
npm packages: @json-render/core, @json-render/react, @json-render/next, @json-render/ink, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
|
||||
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, next, ink, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
|
||||
|
||||
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ export const docsNavigation: NavSection[] = [
|
||||
items: [
|
||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||
{ title: "@json-render/next", href: "/docs/api/next" },
|
||||
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||
{ title: "@json-render/react-email", href: "/docs/api/react-email" },
|
||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||
|
||||
@@ -43,6 +43,7 @@ export const PAGE_TITLES: Record<string, string> = {
|
||||
// API references
|
||||
"docs/api/core": "@json-render/core API",
|
||||
"docs/api/react": "@json-render/react API",
|
||||
"docs/api/next": "@json-render/next API",
|
||||
"docs/api/vue": "@json-render/vue API",
|
||||
"docs/api/solid": "@json-render/solid API",
|
||||
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { NextAppProvider } from "@json-render/next";
|
||||
import { registry } from "@/lib/registry";
|
||||
|
||||
export default function WebsiteLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <NextAppProvider registry={registry}>{children}</NextAppProvider>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
|
||||
import { WebsiteRenderer } from "./renderer";
|
||||
|
||||
export { generateMetadata, generateStaticParams };
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const data = await getPageData({ params });
|
||||
if (!data) notFound();
|
||||
return <WebsiteRenderer {...data} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { PageRenderer, type PageRendererProps } from "@json-render/next";
|
||||
|
||||
export function WebsiteRenderer(props: PageRendererProps) {
|
||||
return <PageRenderer {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { NextAppSpec } from "@json-render/next";
|
||||
import { getSpec, setSpec } from "@/lib/spec-store";
|
||||
|
||||
export async function GET() {
|
||||
return Response.json(getSpec());
|
||||
}
|
||||
|
||||
export async function PUT(req: Request) {
|
||||
setSpec((await req.json()) as NextAppSpec);
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Editor } from "@/components/editor";
|
||||
|
||||
export default function BuilderPage() {
|
||||
return <Editor />;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@source "../../../packages/shadcn/src/**/*.tsx";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { GeistSans } from "geist/font/sans";
|
||||
import { GeistMono } from "geist/font/mono";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Next Website Builder | json-render",
|
||||
description:
|
||||
"Build entire Next.js websites from JSON specs with @json-render/next",
|
||||
icons: { icon: "/icon.svg" },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" className={`${GeistSans.variable} ${GeistMono.variable}`}>
|
||||
<body className="font-sans antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
} from "react";
|
||||
import { JsonEditor, type JsonValue } from "@visual-json/react";
|
||||
import type { NextAppSpec } from "@json-render/next";
|
||||
import { NextAppProvider, PageRenderer } from "@json-render/next";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
ResizableHandle,
|
||||
} from "@/components/ui/resizable";
|
||||
import { AddressBar } from "@/components/route-tabs";
|
||||
import { registry } from "@/lib/registry";
|
||||
|
||||
export function Editor() {
|
||||
const [spec, setSpec] = useState<NextAppSpec | null>(null);
|
||||
const [activeRoute, setActiveRoute] = useState("/");
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/spec")
|
||||
.then((r) => r.json())
|
||||
.then((data: NextAppSpec) => setSpec(data));
|
||||
}, []);
|
||||
|
||||
const handleChange = useCallback((value: JsonValue) => {
|
||||
const updated = value as unknown as NextAppSpec;
|
||||
setSpec(updated);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetch("/api/spec", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updated),
|
||||
});
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const handlePreviewClick = useCallback((e: MouseEvent<HTMLDivElement>) => {
|
||||
const anchor = (e.target as HTMLElement).closest("a");
|
||||
if (!anchor) return;
|
||||
const href = anchor.getAttribute("href");
|
||||
if (!href || href.startsWith("http") || href.startsWith("mailto:")) return;
|
||||
e.preventDefault();
|
||||
setActiveRoute(href);
|
||||
}, []);
|
||||
|
||||
const currentRoute = useMemo(() => {
|
||||
if (!spec) return null;
|
||||
return spec.routes[activeRoute] ?? null;
|
||||
}, [spec, activeRoute]);
|
||||
|
||||
const layoutSpec = useMemo(() => {
|
||||
if (!spec || !currentRoute?.layout || !spec.layouts) return null;
|
||||
return spec.layouts[currentRoute.layout] ?? null;
|
||||
}, [spec, currentRoute]);
|
||||
|
||||
const initialState = useMemo(() => {
|
||||
if (!spec || !currentRoute) return undefined;
|
||||
const merged: Record<string, unknown> = {};
|
||||
if (spec.state) Object.assign(merged, spec.state);
|
||||
if (currentRoute.page.state) Object.assign(merged, currentRoute.page.state);
|
||||
return Object.keys(merged).length > 0 ? merged : undefined;
|
||||
}, [spec, currentRoute]);
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 h-12 border-b border-border bg-background shrink-0">
|
||||
<span className="text-sm font-semibold">Next Website Builder</span>
|
||||
<a
|
||||
href="/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
View Website
|
||||
</a>
|
||||
</div>
|
||||
<ResizablePanelGroup orientation="horizontal" className="flex-1">
|
||||
<ResizablePanel defaultSize={45} minSize={25}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 h-10 border-b border-border bg-muted/30">
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
spec.json
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setSidebarOpen((v) => !v)}
|
||||
className="flex items-center justify-center w-6 h-6 rounded text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
title={sidebarOpen ? "Hide sidebar" : "Show sidebar"}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" />
|
||||
<path d="M9 3v18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<JsonEditor
|
||||
value={spec as unknown as JsonValue}
|
||||
onChange={handleChange}
|
||||
sidebarOpen={sidebarOpen}
|
||||
height="100%"
|
||||
className="h-full"
|
||||
style={
|
||||
{
|
||||
"--vj-bg": "var(--background)",
|
||||
"--vj-bg-panel": "var(--background)",
|
||||
"--vj-bg-hover": "var(--muted)",
|
||||
"--vj-bg-selected": "var(--primary)",
|
||||
"--vj-bg-selected-muted": "var(--muted)",
|
||||
"--vj-text": "var(--foreground)",
|
||||
"--vj-text-selected": "var(--primary-foreground)",
|
||||
"--vj-text-muted": "var(--muted-foreground)",
|
||||
"--vj-text-dim": "var(--muted-foreground)",
|
||||
"--vj-border": "var(--border)",
|
||||
"--vj-border-subtle": "var(--border)",
|
||||
"--vj-accent": "var(--primary)",
|
||||
"--vj-accent-muted": "var(--muted)",
|
||||
"--vj-input-bg": "var(--secondary)",
|
||||
"--vj-input-border": "var(--border)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={55} minSize={30}>
|
||||
<div className="h-full flex flex-col">
|
||||
<AddressBar route={activeRoute} onNavigate={setActiveRoute} />
|
||||
<div
|
||||
className="flex-1 overflow-auto bg-background"
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
{currentRoute ? (
|
||||
<NextAppProvider registry={registry}>
|
||||
<PageRenderer
|
||||
spec={currentRoute.page}
|
||||
initialState={initialState}
|
||||
layoutSpec={layoutSpec}
|
||||
/>
|
||||
</NextAppProvider>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
Route not found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, type KeyboardEvent } from "react";
|
||||
|
||||
interface AddressBarProps {
|
||||
route: string;
|
||||
onNavigate: (route: string) => void;
|
||||
}
|
||||
|
||||
export function AddressBar({ route, onNavigate }: AddressBarProps) {
|
||||
const [value, setValue] = useState(route);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const commit = useCallback(() => {
|
||||
let normalized = value.trim();
|
||||
if (!normalized.startsWith("/")) normalized = "/" + normalized;
|
||||
onNavigate(normalized);
|
||||
setValue(normalized);
|
||||
}, [value, onNavigate]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
commit();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
},
|
||||
[commit],
|
||||
);
|
||||
|
||||
const displayValue = focused ? value : route;
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-10 px-2 border-b border-border bg-muted/30">
|
||||
<div className="flex items-center flex-1 h-7 rounded-md border border-border bg-background px-2 gap-1.5">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-muted-foreground shrink-0"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={displayValue}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={() => {
|
||||
setFocused(true);
|
||||
setValue(route);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commit();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
spellCheck={false}
|
||||
className="flex-1 bg-transparent text-xs font-mono text-foreground outline-none placeholder:text-muted-foreground"
|
||||
placeholder="/"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.GroupProps) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full aria-[orientation=vertical]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ResizablePanel({ ...props }: ResizablePrimitive.PanelProps) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: ResizablePrimitive.SeparatorProps & {
|
||||
withHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=horizontal]:h-px aria-[orientation=horizontal]:w-full aria-[orientation=horizontal]:after:left-0 aria-[orientation=horizontal]:after:h-1 aria-[orientation=horizontal]:after:w-full aria-[orientation=horizontal]:after:translate-x-0 aria-[orientation=horizontal]:after:-translate-y-1/2 [&[aria-orientation=horizontal]>div]:rotate-90",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
}
|
||||
|
||||
export { ResizableHandle, ResizablePanel, ResizablePanelGroup };
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createNextApp } from "@json-render/next/server";
|
||||
import { getSpec } from "./spec-store";
|
||||
|
||||
export const { getPageData, generateMetadata, generateStaticParams } =
|
||||
createNextApp({
|
||||
spec: () => getSpec(),
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/next/server";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn/catalog";
|
||||
import { websiteComponentDefinitions } from "./website-catalog";
|
||||
|
||||
export const catalog = defineCatalog(schema, {
|
||||
components: {
|
||||
...shadcnComponentDefinitions,
|
||||
...websiteComponentDefinitions,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
@@ -0,0 +1,307 @@
|
||||
import type { NextAppSpec } from "@json-render/next";
|
||||
|
||||
export const defaultSpec: NextAppSpec = {
|
||||
metadata: {
|
||||
title: {
|
||||
default: "Acme Inc",
|
||||
template: "%s | Acme Inc",
|
||||
},
|
||||
description: "We build the future of software.",
|
||||
icons: "/icon.svg",
|
||||
},
|
||||
|
||||
state: {},
|
||||
|
||||
layouts: {
|
||||
main: {
|
||||
root: "wrapper",
|
||||
elements: {
|
||||
wrapper: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "none",
|
||||
align: "stretch",
|
||||
className: "min-h-screen",
|
||||
},
|
||||
children: ["header", "content", "footer"],
|
||||
},
|
||||
header: {
|
||||
type: "Header",
|
||||
props: {
|
||||
brand: "Acme Inc",
|
||||
links: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
variant: "simple",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
type: "Slot",
|
||||
props: {},
|
||||
},
|
||||
footer: {
|
||||
type: "Footer",
|
||||
props: {
|
||||
brand: "Acme Inc",
|
||||
links: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
copyright: "2026 Acme Inc. All rights reserved.",
|
||||
variant: "simple",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
"/": {
|
||||
layout: "main",
|
||||
metadata: {
|
||||
title: "Home",
|
||||
description: "Welcome to Acme Inc - we build the future of software.",
|
||||
},
|
||||
page: {
|
||||
root: "page",
|
||||
elements: {
|
||||
page: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "none",
|
||||
align: "stretch",
|
||||
},
|
||||
children: ["hero", "features", "testimonials", "cta"],
|
||||
},
|
||||
hero: {
|
||||
type: "Hero",
|
||||
props: {
|
||||
headline: "Build the future with Acme",
|
||||
description:
|
||||
"We help companies ship better software, faster. Our platform provides the tools, infrastructure, and insights you need to build world-class products.",
|
||||
primaryCta: { label: "Get Started", href: "/contact" },
|
||||
secondaryCta: { label: "Learn More", href: "/about" },
|
||||
badge: "Now in Beta",
|
||||
variant: "centered",
|
||||
},
|
||||
},
|
||||
features: {
|
||||
type: "Features",
|
||||
props: {
|
||||
headline: "Why Acme?",
|
||||
description:
|
||||
"Everything you need to build, deploy, and scale your applications.",
|
||||
items: [
|
||||
{
|
||||
title: "Lightning Fast",
|
||||
description:
|
||||
"Optimized for speed with edge-first architecture and smart caching.",
|
||||
},
|
||||
{
|
||||
title: "Secure by Default",
|
||||
description:
|
||||
"Enterprise-grade security with end-to-end encryption and SOC 2 compliance.",
|
||||
},
|
||||
{
|
||||
title: "Developer First",
|
||||
description:
|
||||
"APIs, SDKs, and CLI tools designed to fit your workflow perfectly.",
|
||||
},
|
||||
],
|
||||
columns: 3,
|
||||
variant: "cards",
|
||||
},
|
||||
},
|
||||
testimonials: {
|
||||
type: "Testimonials",
|
||||
props: {
|
||||
headline: "Trusted by industry leaders",
|
||||
items: [
|
||||
{
|
||||
quote:
|
||||
"Acme has completely transformed how we ship products. Our deployment time went from hours to minutes.",
|
||||
author: "Jordan Lee",
|
||||
role: "VP Engineering at Globex",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"The developer experience is unmatched. Our team onboarded in a single afternoon.",
|
||||
author: "Priya Patel",
|
||||
role: "CTO at Initech",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"We cut our infrastructure costs by 40% within the first quarter of switching to Acme.",
|
||||
author: "Sam Ortiz",
|
||||
role: "Head of Platform at Hooli",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
cta: {
|
||||
type: "CTA",
|
||||
props: {
|
||||
headline: "Ready to get started?",
|
||||
description:
|
||||
"Join thousands of teams building better software with Acme.",
|
||||
buttonLabel: "Contact Us",
|
||||
buttonHref: "/contact",
|
||||
variant: "banner",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/about": {
|
||||
layout: "main",
|
||||
metadata: {
|
||||
title: "About",
|
||||
description:
|
||||
"Learn about our mission, values, and the team behind Acme.",
|
||||
},
|
||||
page: {
|
||||
root: "page",
|
||||
elements: {
|
||||
page: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "none",
|
||||
align: "stretch",
|
||||
},
|
||||
children: ["hero", "features", "team", "cta"],
|
||||
},
|
||||
hero: {
|
||||
type: "Hero",
|
||||
props: {
|
||||
headline: "About Acme Inc",
|
||||
description:
|
||||
"Founded in 2024, we are on a mission to make software development accessible to everyone. We believe the best tools should be simple, powerful, and delightful to use.",
|
||||
variant: "centered",
|
||||
primaryCta: null,
|
||||
secondaryCta: null,
|
||||
badge: null,
|
||||
},
|
||||
},
|
||||
features: {
|
||||
type: "Features",
|
||||
props: {
|
||||
headline: "Our Values",
|
||||
items: [
|
||||
{
|
||||
title: "Simplicity",
|
||||
description:
|
||||
"We remove complexity so you can focus on building great products.",
|
||||
},
|
||||
{
|
||||
title: "Transparency",
|
||||
description:
|
||||
"Open communication, honest pricing, and clear documentation.",
|
||||
},
|
||||
{
|
||||
title: "Craftsmanship",
|
||||
description:
|
||||
"Every detail matters. We sweat the small stuff so you don't have to.",
|
||||
},
|
||||
],
|
||||
columns: 3,
|
||||
variant: "simple",
|
||||
description: null,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
type: "Team",
|
||||
props: {
|
||||
headline: "Meet the Team",
|
||||
description:
|
||||
"The people behind Acme who are passionate about building great developer tools.",
|
||||
members: [
|
||||
{
|
||||
name: "Alex Chen",
|
||||
role: "CEO & Co-founder",
|
||||
bio: "Previously at Vercel and Google. Passionate about developer experience.",
|
||||
},
|
||||
{
|
||||
name: "Sarah Kim",
|
||||
role: "CTO & Co-founder",
|
||||
bio: "Built distributed systems at AWS. Loves solving hard infrastructure problems.",
|
||||
},
|
||||
{
|
||||
name: "Marcus Rivera",
|
||||
role: "Head of Design",
|
||||
bio: "Former design lead at Figma and Stripe. Obsessed with clarity and craft.",
|
||||
},
|
||||
],
|
||||
variant: "grid",
|
||||
},
|
||||
},
|
||||
cta: {
|
||||
type: "CTA",
|
||||
props: {
|
||||
headline: "Want to join us?",
|
||||
description:
|
||||
"We are always looking for talented people to join our team.",
|
||||
buttonLabel: "Get in Touch",
|
||||
buttonHref: "/contact",
|
||||
variant: "centered",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
"/contact": {
|
||||
layout: "main",
|
||||
metadata: {
|
||||
title: "Contact",
|
||||
description: "Get in touch with the Acme team.",
|
||||
},
|
||||
page: {
|
||||
root: "page",
|
||||
elements: {
|
||||
page: {
|
||||
type: "Stack",
|
||||
props: {
|
||||
direction: "vertical",
|
||||
gap: "none",
|
||||
align: "stretch",
|
||||
},
|
||||
children: ["form"],
|
||||
},
|
||||
form: {
|
||||
type: "ContactForm",
|
||||
props: {
|
||||
headline: "Get in Touch",
|
||||
description:
|
||||
"Have a question or want to work with us? Fill out the form below and we'll get back to you within 24 hours.",
|
||||
fields: [
|
||||
{
|
||||
label: "Name",
|
||||
type: "text" as const,
|
||||
placeholder: "Your name",
|
||||
},
|
||||
{
|
||||
label: "Email",
|
||||
type: "email" as const,
|
||||
placeholder: "you@example.com",
|
||||
},
|
||||
{
|
||||
label: "Message",
|
||||
type: "textarea" as const,
|
||||
placeholder: "Tell us about your project...",
|
||||
},
|
||||
],
|
||||
submitLabel: "Send Message",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { defineRegistry } from "@json-render/react";
|
||||
import { shadcnComponents } from "@json-render/shadcn";
|
||||
import { catalog } from "./catalog";
|
||||
import { websiteComponents } from "./website-components";
|
||||
|
||||
export const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
...shadcnComponents,
|
||||
...websiteComponents,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextAppSpec } from "@json-render/next";
|
||||
import { defaultSpec } from "./default-spec";
|
||||
|
||||
let currentSpec: NextAppSpec = defaultSpec;
|
||||
|
||||
export function getSpec(): NextAppSpec {
|
||||
return currentSpec;
|
||||
}
|
||||
|
||||
export function setSpec(spec: NextAppSpec): void {
|
||||
currentSpec = spec;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const linkSchema = z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
});
|
||||
|
||||
const ctaSchema = z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
});
|
||||
|
||||
export const websiteComponentDefinitions = {
|
||||
Header: {
|
||||
props: z.object({
|
||||
brand: z.string(),
|
||||
links: z.array(linkSchema),
|
||||
variant: z.enum(["simple", "centered"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Site header / navigation bar. Simple: brand left, links right. Centered: brand centered above links.",
|
||||
example: {
|
||||
brand: "Acme Inc",
|
||||
links: [
|
||||
{ label: "Home", href: "/" },
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "Contact", href: "/contact" },
|
||||
],
|
||||
variant: "simple",
|
||||
},
|
||||
},
|
||||
|
||||
Hero: {
|
||||
props: z.object({
|
||||
headline: z.string(),
|
||||
description: z.string(),
|
||||
primaryCta: ctaSchema.nullable(),
|
||||
secondaryCta: ctaSchema.nullable(),
|
||||
badge: z.string().nullable(),
|
||||
variant: z.enum(["centered", "left-aligned"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Landing hero section with headline, description, and call-to-action buttons.",
|
||||
example: {
|
||||
headline: "Build something great",
|
||||
description: "A modern platform for modern teams.",
|
||||
primaryCta: { label: "Get Started", href: "/contact" },
|
||||
variant: "centered",
|
||||
},
|
||||
},
|
||||
|
||||
Features: {
|
||||
props: z.object({
|
||||
headline: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
}),
|
||||
),
|
||||
columns: z.number().nullable(),
|
||||
variant: z.enum(["cards", "simple"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Feature highlights section. Cards: bordered cards per item. Simple: clean text layout.",
|
||||
example: {
|
||||
headline: "Why choose us",
|
||||
items: [
|
||||
{ title: "Fast", description: "Blazing performance." },
|
||||
{ title: "Secure", description: "Enterprise-grade security." },
|
||||
{ title: "Simple", description: "Easy to use." },
|
||||
],
|
||||
columns: 3,
|
||||
variant: "cards",
|
||||
},
|
||||
},
|
||||
|
||||
Team: {
|
||||
props: z.object({
|
||||
headline: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
members: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
role: z.string(),
|
||||
bio: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
variant: z.enum(["grid", "list"]).nullable(),
|
||||
}),
|
||||
description: "Team members section. Grid: card grid. List: vertical list.",
|
||||
example: {
|
||||
headline: "Our Team",
|
||||
members: [
|
||||
{ name: "Alice", role: "CEO", bio: null },
|
||||
{ name: "Bob", role: "CTO", bio: null },
|
||||
],
|
||||
variant: "grid",
|
||||
},
|
||||
},
|
||||
|
||||
Testimonials: {
|
||||
props: z.object({
|
||||
headline: z.string().nullable(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
quote: z.string(),
|
||||
author: z.string(),
|
||||
role: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Customer testimonials / quotes section.",
|
||||
example: {
|
||||
headline: "What people say",
|
||||
items: [
|
||||
{
|
||||
quote: "Amazing product!",
|
||||
author: "Jane",
|
||||
role: "CEO at Startup",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
ContactForm: {
|
||||
props: z.object({
|
||||
headline: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
type: z.enum(["text", "email", "textarea"]),
|
||||
placeholder: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
submitLabel: z.string(),
|
||||
}),
|
||||
description: "Contact form section with configurable fields.",
|
||||
example: {
|
||||
headline: "Get in Touch",
|
||||
fields: [
|
||||
{ label: "Name", type: "text", placeholder: "Your name" },
|
||||
{ label: "Email", type: "email", placeholder: "you@example.com" },
|
||||
{ label: "Message", type: "textarea", placeholder: "Your message" },
|
||||
],
|
||||
submitLabel: "Send",
|
||||
},
|
||||
},
|
||||
|
||||
Footer: {
|
||||
props: z.object({
|
||||
brand: z.string().nullable(),
|
||||
links: z.array(linkSchema).nullable(),
|
||||
copyright: z.string().nullable(),
|
||||
variant: z.enum(["simple", "columns"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Site footer. Simple: centered single row. Columns: multi-column layout.",
|
||||
example: {
|
||||
brand: "Acme Inc",
|
||||
copyright: "2026 Acme Inc.",
|
||||
variant: "simple",
|
||||
},
|
||||
},
|
||||
|
||||
CTA: {
|
||||
props: z.object({
|
||||
headline: z.string(),
|
||||
description: z.string().nullable(),
|
||||
buttonLabel: z.string(),
|
||||
buttonHref: z.string(),
|
||||
variant: z.enum(["banner", "centered"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Call-to-action section. Banner: full-width colored background. Centered: clean centered layout.",
|
||||
example: {
|
||||
headline: "Ready to get started?",
|
||||
buttonLabel: "Contact Us",
|
||||
buttonHref: "/contact",
|
||||
variant: "centered",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type WebsiteProps<K extends keyof typeof websiteComponentDefinitions> =
|
||||
z.output<(typeof websiteComponentDefinitions)[K]["props"]>;
|
||||
@@ -0,0 +1,501 @@
|
||||
"use client";
|
||||
|
||||
import type { BaseComponentProps } from "@json-render/react";
|
||||
import type { WebsiteProps } from "./website-catalog";
|
||||
|
||||
// =============================================================================
|
||||
// Header
|
||||
// =============================================================================
|
||||
|
||||
function HeaderComponent({
|
||||
props,
|
||||
}: BaseComponentProps<WebsiteProps<"Header">>) {
|
||||
const variant = props.variant ?? "simple";
|
||||
const links = props.links ?? [];
|
||||
|
||||
if (variant === "centered") {
|
||||
return (
|
||||
<header className="border-b border-border bg-background">
|
||||
<div className="mx-auto max-w-6xl px-6 py-6 flex flex-col items-center gap-4">
|
||||
<span className="text-xl font-bold tracking-tight">
|
||||
{props.brand}
|
||||
</span>
|
||||
<nav className="flex items-center gap-6">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-background sticky top-0 z-50">
|
||||
<div className="mx-auto max-w-6xl px-6 h-16 flex items-center justify-between">
|
||||
<span className="text-lg font-bold tracking-tight">{props.brand}</span>
|
||||
<nav className="flex items-center gap-6">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hero
|
||||
// =============================================================================
|
||||
|
||||
function HeroComponent({ props }: BaseComponentProps<WebsiteProps<"Hero">>) {
|
||||
const variant = props.variant ?? "centered";
|
||||
const isLeft = variant === "left-aligned";
|
||||
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<div
|
||||
className={`mx-auto max-w-4xl px-6 py-24 md:py-32 ${isLeft ? "text-left" : "text-center"}`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col gap-6 ${isLeft ? "items-start" : "items-center"}`}
|
||||
>
|
||||
{props.badge && (
|
||||
<span className="inline-flex items-center rounded-full border border-border bg-muted px-3 py-1 text-xs font-medium text-muted-foreground">
|
||||
{props.badge}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-5xl lg:text-6xl text-foreground">
|
||||
{props.headline}
|
||||
</h1>
|
||||
<p
|
||||
className={`text-lg text-muted-foreground ${isLeft ? "max-w-xl" : "max-w-2xl"}`}
|
||||
>
|
||||
{props.description}
|
||||
</p>
|
||||
{(props.primaryCta || props.secondaryCta) && (
|
||||
<div className="flex flex-wrap gap-3 pt-2">
|
||||
{props.primaryCta && (
|
||||
<a
|
||||
href={props.primaryCta.href}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{props.primaryCta.label}
|
||||
</a>
|
||||
)}
|
||||
{props.secondaryCta && (
|
||||
<a
|
||||
href={props.secondaryCta.href}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md border border-border bg-background px-6 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{props.secondaryCta.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Features
|
||||
// =============================================================================
|
||||
|
||||
function FeaturesComponent({
|
||||
props,
|
||||
}: BaseComponentProps<WebsiteProps<"Features">>) {
|
||||
const variant = props.variant ?? "cards";
|
||||
const columns = props.columns ?? 3;
|
||||
const items = props.items ?? [];
|
||||
|
||||
const colClass =
|
||||
columns === 2
|
||||
? "sm:grid-cols-2"
|
||||
: columns === 4
|
||||
? "sm:grid-cols-2 lg:grid-cols-4"
|
||||
: "sm:grid-cols-2 lg:grid-cols-3";
|
||||
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<div className="mx-auto max-w-6xl px-6 py-20">
|
||||
{(props.headline || props.description) && (
|
||||
<div className="mb-12 text-center">
|
||||
{props.headline && (
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{props.headline}
|
||||
</h2>
|
||||
)}
|
||||
{props.description && (
|
||||
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`grid grid-cols-1 gap-6 ${colClass}`}>
|
||||
{items.map((item, i) =>
|
||||
variant === "cards" ? (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-card p-6 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-card-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="py-2">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Team
|
||||
// =============================================================================
|
||||
|
||||
function TeamComponent({ props }: BaseComponentProps<WebsiteProps<"Team">>) {
|
||||
const variant = props.variant ?? "grid";
|
||||
const members = props.members ?? [];
|
||||
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<div className="mx-auto max-w-6xl px-6 py-20">
|
||||
{(props.headline || props.description) && (
|
||||
<div className="mb-12 text-center">
|
||||
{props.headline && (
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{props.headline}
|
||||
</h2>
|
||||
)}
|
||||
{props.description && (
|
||||
<p className="mt-3 text-lg text-muted-foreground max-w-2xl mx-auto">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{variant === "list" ? (
|
||||
<div className="space-y-6 max-w-2xl mx-auto">
|
||||
{members.map((m, i) => (
|
||||
<div key={i} className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{m.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{m.role}</p>
|
||||
{m.bio && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{m.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{members.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-card p-6 text-center"
|
||||
>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-bold text-muted-foreground">
|
||||
{m.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-card-foreground">
|
||||
{m.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">{m.role}</p>
|
||||
{m.bio && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{m.bio}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Testimonials
|
||||
// =============================================================================
|
||||
|
||||
function TestimonialsComponent({
|
||||
props,
|
||||
}: BaseComponentProps<WebsiteProps<"Testimonials">>) {
|
||||
const items = props.items ?? [];
|
||||
|
||||
return (
|
||||
<section className="bg-muted/30">
|
||||
<div className="mx-auto max-w-6xl px-6 py-20">
|
||||
{props.headline && (
|
||||
<h2 className="mb-12 text-center text-3xl font-bold tracking-tight text-foreground">
|
||||
{props.headline}
|
||||
</h2>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((item, i) => (
|
||||
<blockquote
|
||||
key={i}
|
||||
className="rounded-lg border border-border bg-background p-6"
|
||||
>
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
“{item.quote}”
|
||||
</p>
|
||||
<footer className="mt-4 flex flex-col">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{item.author}
|
||||
</span>
|
||||
{item.role && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.role}
|
||||
</span>
|
||||
)}
|
||||
</footer>
|
||||
</blockquote>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ContactForm
|
||||
// =============================================================================
|
||||
|
||||
function ContactFormComponent({
|
||||
props,
|
||||
}: BaseComponentProps<WebsiteProps<"ContactForm">>) {
|
||||
const fields = props.fields ?? [];
|
||||
|
||||
return (
|
||||
<section className="bg-background">
|
||||
<div className="mx-auto max-w-xl px-6 py-20">
|
||||
{(props.headline || props.description) && (
|
||||
<div className="mb-8 text-center">
|
||||
{props.headline && (
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{props.headline}
|
||||
</h2>
|
||||
)}
|
||||
{props.description && (
|
||||
<p className="mt-3 text-muted-foreground">{props.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
className="rounded-lg border border-border bg-card p-6 shadow-sm"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{fields.map((field, i) =>
|
||||
field.type === "textarea" ? (
|
||||
<div key={i} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<textarea
|
||||
placeholder={field.placeholder ?? ""}
|
||||
rows={4}
|
||||
className="rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div key={i} className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type={field.type}
|
||||
placeholder={field.placeholder ?? ""}
|
||||
className="h-10 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="mt-2 h-11 rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{props.submitLabel}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Footer
|
||||
// =============================================================================
|
||||
|
||||
function FooterComponent({
|
||||
props,
|
||||
}: BaseComponentProps<WebsiteProps<"Footer">>) {
|
||||
const variant = props.variant ?? "simple";
|
||||
const links = props.links ?? [];
|
||||
|
||||
if (variant === "columns") {
|
||||
return (
|
||||
<footer className="border-t border-border bg-muted/30">
|
||||
<div className="mx-auto max-w-6xl px-6 py-12">
|
||||
<div className="flex flex-col gap-8 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
{props.brand && (
|
||||
<span className="text-lg font-bold text-foreground">
|
||||
{props.brand}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{links.length > 0 && (
|
||||
<nav className="flex flex-col gap-2 sm:flex-row sm:gap-6">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
{props.copyright && (
|
||||
<p className="mt-8 text-xs text-muted-foreground">
|
||||
{props.copyright}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="border-t border-border bg-muted/30">
|
||||
<div className="mx-auto max-w-6xl px-6 py-10 flex flex-col items-center gap-4">
|
||||
{props.brand && (
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{props.brand}
|
||||
</span>
|
||||
)}
|
||||
{links.length > 0 && (
|
||||
<nav className="flex items-center gap-4">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{props.copyright && (
|
||||
<p className="text-xs text-muted-foreground">{props.copyright}</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CTA
|
||||
// =============================================================================
|
||||
|
||||
function CTAComponent({ props }: BaseComponentProps<WebsiteProps<"CTA">>) {
|
||||
const variant = props.variant ?? "centered";
|
||||
|
||||
if (variant === "banner") {
|
||||
return (
|
||||
<section className="bg-primary text-primary-foreground">
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 flex flex-col items-center gap-4 sm:flex-row sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{props.headline}</h2>
|
||||
{props.description && (
|
||||
<p className="mt-1 text-sm opacity-90">{props.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={props.buttonHref}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md bg-background px-6 text-sm font-medium text-foreground shadow-sm hover:bg-background/90 transition-colors shrink-0"
|
||||
>
|
||||
{props.buttonLabel}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/30">
|
||||
<div className="mx-auto max-w-3xl px-6 py-20 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
{props.headline}
|
||||
</h2>
|
||||
{props.description && (
|
||||
<p className="mt-3 text-lg text-muted-foreground">
|
||||
{props.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-8">
|
||||
<a
|
||||
href={props.buttonHref}
|
||||
className="inline-flex h-11 items-center justify-center rounded-md bg-primary px-8 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
{props.buttonLabel}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Export map
|
||||
// =============================================================================
|
||||
|
||||
export const websiteComponents = {
|
||||
Header: HeaderComponent,
|
||||
Hero: HeroComponent,
|
||||
Features: FeaturesComponent,
|
||||
Team: TeamComponent,
|
||||
Testimonials: TestimonialsComponent,
|
||||
ContactForm: ContactFormComponent,
|
||||
Footer: FooterComponent,
|
||||
CTA: CTAComponent,
|
||||
};
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
allowedDevOrigins: ["next-website-builder-demo.json-render.localhost"],
|
||||
transpilePackages: [
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
"@json-render/shadcn",
|
||||
],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "example-next-website-builder",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
|
||||
"dev": "portless next-website-builder-demo.json-render next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*",
|
||||
"@json-render/next": "workspace:*",
|
||||
"@json-render/shadcn": "workspace:*",
|
||||
"@visual-json/react": "0.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"geist": "^1.7.0",
|
||||
"lucide-react": "^1.6.0",
|
||||
"next": "16.2.1",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-resizable-panels": "^4.7.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/react": "19.2.3",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# @json-render/next
|
||||
|
||||
Next.js renderer for [@json-render/core](https://json-render.dev). JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/core @json-render/react @json-render/next
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Define your application spec
|
||||
|
||||
```typescript
|
||||
import type { NextAppSpec } from "@json-render/next";
|
||||
|
||||
const spec: NextAppSpec = {
|
||||
metadata: {
|
||||
title: { default: "My App", template: "%s | My App" },
|
||||
},
|
||||
layouts: {
|
||||
main: {
|
||||
root: "shell",
|
||||
elements: {
|
||||
shell: { type: "Container", props: {}, children: ["nav", "slot"] },
|
||||
nav: { type: "NavBar", props: {}, children: [] },
|
||||
slot: { type: "Slot", props: {}, children: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
"/": {
|
||||
layout: "main",
|
||||
metadata: { title: "Home" },
|
||||
page: { root: "hero", elements: { hero: { type: "Card", props: { title: "Welcome" }, children: [] } } },
|
||||
},
|
||||
"/about": {
|
||||
layout: "main",
|
||||
metadata: { title: "About" },
|
||||
page: { root: "content", elements: { content: { type: "Card", props: { title: "About" }, children: [] } } },
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Create the app
|
||||
|
||||
```typescript
|
||||
// lib/app.ts
|
||||
import { createNextApp } from "@json-render/next/server";
|
||||
|
||||
export const { Page, generateMetadata, generateStaticParams } = createNextApp({ spec });
|
||||
```
|
||||
|
||||
### 3. Wire up Next.js routes
|
||||
|
||||
```tsx
|
||||
// app/[[...slug]]/page.tsx
|
||||
export { Page as default, generateMetadata, generateStaticParams } from "@/lib/app";
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/[[...slug]]/layout.tsx
|
||||
import { NextAppProvider } from "@json-render/next";
|
||||
import { registry, handlers } from "@/lib/registry";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<NextAppProvider registry={registry} handlers={handlers}>
|
||||
{children}
|
||||
</NextAppProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Pages as spec** -- Define entire multi-page apps in JSON
|
||||
- **Route matching** -- Dynamic segments (`[slug]`), catch-all (`[...path]`), optional catch-all (`[[...path]]`)
|
||||
- **Nested layouts** -- Reusable layouts with `Slot` component for content injection
|
||||
- **SEO metadata** -- Per-route metadata with title templates, OpenGraph, Twitter cards
|
||||
- **SSR** -- Server-side rendering via Next.js App Router
|
||||
- **Data loaders** -- Server-side async data loading before page render
|
||||
- **Static generation** -- `generateStaticParams` for pre-rendering
|
||||
- **Client navigation** -- Built-in `Link` component wrapping `next/link`
|
||||
- **Error/Loading/NotFound** -- Per-route error boundaries, loading states, and 404 pages
|
||||
- **AI streaming** -- Generate entire apps with JSONL patches via `SpecStream`
|
||||
|
||||
## Entry Points
|
||||
|
||||
| Import | Description |
|
||||
| -------------------------- | ------------------------------------------------ |
|
||||
| `@json-render/next` | Client components (NextAppProvider, PageRenderer) |
|
||||
| `@json-render/next/server` | Server utilities (createNextApp, matchRoute) |
|
||||
|
||||
## Documentation
|
||||
|
||||
See the [json-render documentation](https://json-render.dev/docs/api/next) for full API reference.
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@json-render/next",
|
||||
"version": "0.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "Next.js renderer for @json-render/core. JSON becomes full Next.js applications with routes, layouts, metadata, and SSR.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"next",
|
||||
"nextjs",
|
||||
"ai",
|
||||
"generative-ui",
|
||||
"llm",
|
||||
"renderer",
|
||||
"streaming",
|
||||
"ssr",
|
||||
"pages",
|
||||
"routing"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/next"
|
||||
},
|
||||
"homepage": "https://json-render.dev",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/react": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@types/react": "19.2.14",
|
||||
"next": "16.2.1",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "^16.2.0",
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Catalog-aware types for @json-render/next.
|
||||
*
|
||||
* Re-exports the React catalog types since Next.js uses the same
|
||||
* component and action model as @json-render/react.
|
||||
*/
|
||||
export type {
|
||||
EventHandle,
|
||||
BaseComponentProps,
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "@json-render/react";
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { Renderer, type ComponentRegistry } from "@json-render/react";
|
||||
import { useNextApp } from "./provider";
|
||||
|
||||
/**
|
||||
* Props for NextErrorBoundary.
|
||||
*/
|
||||
export interface NextErrorBoundaryProps {
|
||||
/** The error that occurred */
|
||||
error: Error & { digest?: string };
|
||||
/** Function to attempt re-rendering the page */
|
||||
reset: () => void;
|
||||
/** Optional error UI spec from the matched route */
|
||||
errorSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary component for json-render Next.js apps.
|
||||
*
|
||||
* If the matched route defines an `error` spec, it renders that.
|
||||
* Otherwise renders a minimal default error UI.
|
||||
*
|
||||
* Must be used in a file with `"use client"` directive (Next.js requirement).
|
||||
*/
|
||||
export function NextErrorBoundary({
|
||||
error,
|
||||
reset,
|
||||
errorSpec,
|
||||
}: NextErrorBoundaryProps) {
|
||||
let registry: ComponentRegistry | undefined;
|
||||
try {
|
||||
const ctx = useNextApp();
|
||||
registry = ctx.registry;
|
||||
} catch {
|
||||
// Provider may not be available in error boundaries
|
||||
}
|
||||
|
||||
if (errorSpec && registry) {
|
||||
const augmentedRegistry: ComponentRegistry = {
|
||||
...registry,
|
||||
Slot: () => null,
|
||||
};
|
||||
return <Renderer spec={errorSpec} registry={augmentedRegistry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||
<h2 style={{ marginBottom: "1rem" }}>Something went wrong</h2>
|
||||
<p style={{ color: "#666", marginBottom: "1.5rem" }}>
|
||||
{error.message || "An unexpected error occurred."}
|
||||
</p>
|
||||
<button
|
||||
onClick={reset}
|
||||
style={{
|
||||
padding: "0.5rem 1rem",
|
||||
borderRadius: "0.375rem",
|
||||
border: "1px solid #ccc",
|
||||
background: "#fff",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React, { type ReactNode } from "react";
|
||||
import NextLink from "next/link";
|
||||
import type { ComponentRenderProps } from "@json-render/react";
|
||||
|
||||
/**
|
||||
* Props expected by the Link component in json-render specs.
|
||||
*/
|
||||
export interface LinkProps {
|
||||
/** The target URL or route path */
|
||||
href: string;
|
||||
/** Whether to replace the current history entry */
|
||||
replace?: boolean;
|
||||
/** Whether to prefetch the linked route */
|
||||
prefetch?: boolean;
|
||||
/** CSS class name */
|
||||
className?: string;
|
||||
/** Inline styles */
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in Link component for json-render Next.js specs.
|
||||
*
|
||||
* Wraps `next/link` for client-side navigation between routes.
|
||||
*
|
||||
* Usage in a spec:
|
||||
* ```json
|
||||
* {
|
||||
* "type": "Link",
|
||||
* "props": { "href": "/about" },
|
||||
* "children": ["link-text"]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function Link({ element, children }: ComponentRenderProps<LinkProps>) {
|
||||
const { href, replace, prefetch, className, style } =
|
||||
element.props as LinkProps;
|
||||
|
||||
return (
|
||||
<NextLink
|
||||
href={href}
|
||||
replace={replace}
|
||||
prefetch={prefetch}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Props for NextLoading.
|
||||
*/
|
||||
export interface NextLoadingProps {
|
||||
/** Optional loading UI spec */
|
||||
loadingSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading component for json-render Next.js apps.
|
||||
*
|
||||
* If a loading spec is provided, it will be rendered by the PageRenderer.
|
||||
* Otherwise renders a minimal default loading indicator.
|
||||
*
|
||||
* Note: Since Next.js loading.tsx is static (no params), this renders
|
||||
* a generic loading state. Route-specific loading can be achieved with
|
||||
* Suspense boundaries in page content.
|
||||
*/
|
||||
export function NextLoading({ loadingSpec: _loadingSpec }: NextLoadingProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "200px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: "2rem",
|
||||
height: "2rem",
|
||||
border: "2px solid #e5e7eb",
|
||||
borderTopColor: "#3b82f6",
|
||||
borderRadius: "50%",
|
||||
animation: "jr-spin 0.6s linear infinite",
|
||||
}}
|
||||
/>
|
||||
<style>{`@keyframes jr-spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Not-found component for json-render Next.js apps.
|
||||
*
|
||||
* Renders when the pathname does not match any route in the spec.
|
||||
*/
|
||||
export function NextNotFound() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: "400px",
|
||||
padding: "2rem",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<h1 style={{ fontSize: "4rem", fontWeight: 700, margin: 0 }}>404</h1>
|
||||
<p style={{ color: "#666", marginTop: "0.5rem", fontSize: "1.125rem" }}>
|
||||
This page could not be found.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, type ReactNode } from "react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
Renderer,
|
||||
StateProvider,
|
||||
VisibilityProvider,
|
||||
ValidationProvider,
|
||||
ActionProvider,
|
||||
type ComponentRegistry,
|
||||
type ComponentRenderProps,
|
||||
} from "@json-render/react";
|
||||
import { useNextApp } from "./provider";
|
||||
|
||||
/**
|
||||
* Props for PageRenderer.
|
||||
*/
|
||||
export interface PageRendererProps {
|
||||
/** Page element tree spec */
|
||||
spec: Spec;
|
||||
/** Initial state (merged from spec.state, global state, and loader data) */
|
||||
initialState?: Record<string, unknown>;
|
||||
/** Optional layout element tree spec */
|
||||
layoutSpec?: Spec | null;
|
||||
/** Whether the spec is currently loading/streaming */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in Slot component that renders children passed into layouts.
|
||||
*/
|
||||
function SlotComponent({ children }: ComponentRenderProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client component that renders a json-render page within the Next.js app.
|
||||
*
|
||||
* Reads the component registry and action handlers from NextAppProvider context.
|
||||
* Handles layout rendering by injecting page content into a Slot component.
|
||||
*/
|
||||
export function PageRenderer({
|
||||
spec,
|
||||
initialState,
|
||||
layoutSpec,
|
||||
loading,
|
||||
}: PageRendererProps) {
|
||||
const { registry, handlers, navigate } = useNextApp();
|
||||
|
||||
const augmentedRegistry: ComponentRegistry = useMemo(() => {
|
||||
return {
|
||||
...registry,
|
||||
Slot: SlotComponent,
|
||||
};
|
||||
}, [registry]);
|
||||
|
||||
const actionHandlers = useMemo(() => {
|
||||
const base: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
> = { ...handlers };
|
||||
|
||||
base.navigate = (params: Record<string, unknown>) => {
|
||||
const href = params.href as string | undefined;
|
||||
if (href) navigate(href);
|
||||
};
|
||||
|
||||
return base;
|
||||
}, [handlers, navigate]);
|
||||
|
||||
const pageContent = (
|
||||
<Renderer spec={spec} registry={augmentedRegistry} loading={loading} />
|
||||
);
|
||||
|
||||
if (layoutSpec) {
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider>
|
||||
<ActionProvider handlers={actionHandlers} navigate={navigate}>
|
||||
<LayoutWithSlot
|
||||
layoutSpec={layoutSpec}
|
||||
registry={augmentedRegistry}
|
||||
loading={loading}
|
||||
>
|
||||
{pageContent}
|
||||
</LayoutWithSlot>
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<StateProvider initialState={initialState}>
|
||||
<VisibilityProvider>
|
||||
<ValidationProvider>
|
||||
<ActionProvider handlers={actionHandlers} navigate={navigate}>
|
||||
{pageContent}
|
||||
</ActionProvider>
|
||||
</ValidationProvider>
|
||||
</VisibilityProvider>
|
||||
</StateProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a layout spec and injects children into the Slot.
|
||||
*/
|
||||
function LayoutWithSlot({
|
||||
layoutSpec,
|
||||
registry,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
layoutSpec: Spec;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const layoutRegistry: ComponentRegistry = useMemo(() => {
|
||||
return {
|
||||
...registry,
|
||||
Slot: function LayoutSlot() {
|
||||
return <>{children}</>;
|
||||
},
|
||||
};
|
||||
}, [registry, children]);
|
||||
|
||||
return (
|
||||
<Renderer spec={layoutSpec} registry={layoutRegistry} loading={loading} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, type ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { ComponentRegistry } from "@json-render/react";
|
||||
|
||||
/**
|
||||
* Context value holding the component registry, action handlers,
|
||||
* and the Next.js router navigate function.
|
||||
*/
|
||||
export interface NextAppContextValue {
|
||||
registry: ComponentRegistry;
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
navigate: (href: string) => void;
|
||||
}
|
||||
|
||||
const NextAppContext = createContext<NextAppContextValue | null>(null);
|
||||
|
||||
/**
|
||||
* Props for NextAppProvider.
|
||||
*/
|
||||
export interface NextAppProviderProps {
|
||||
/** Component registry for rendering */
|
||||
registry: ComponentRegistry;
|
||||
/** Action handlers */
|
||||
handlers?: Record<
|
||||
string,
|
||||
(params: Record<string, unknown>) => Promise<unknown> | unknown
|
||||
>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider that holds the component registry and action handlers
|
||||
* for the entire json-render Next.js application.
|
||||
*
|
||||
* Wrap your root layout with this provider:
|
||||
*
|
||||
* ```tsx
|
||||
* // app/[[...slug]]/layout.tsx
|
||||
* import { NextAppProvider } from "@json-render/next";
|
||||
* import { registry, handlers } from "@/lib/registry";
|
||||
*
|
||||
* export default function Layout({ children }) {
|
||||
* return (
|
||||
* <html><body>
|
||||
* <NextAppProvider registry={registry} handlers={handlers}>
|
||||
* {children}
|
||||
* </NextAppProvider>
|
||||
* </body></html>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function NextAppProvider({
|
||||
registry,
|
||||
handlers,
|
||||
children,
|
||||
}: NextAppProviderProps) {
|
||||
const router = useRouter();
|
||||
const navigate = React.useCallback(
|
||||
(href: string) => router.push(href),
|
||||
[router],
|
||||
);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ registry, handlers, navigate }),
|
||||
[registry, handlers, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<NextAppContext.Provider value={value}>{children}</NextAppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to access the NextApp context.
|
||||
* Must be used within a NextAppProvider.
|
||||
*/
|
||||
export function useNextApp(): NextAppContextValue {
|
||||
const ctx = useContext(NextAppContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"[json-render/next] useNextApp must be used within a <NextAppProvider>. " +
|
||||
"Wrap your root layout with <NextAppProvider registry={...} handlers={...}>.",
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import type {
|
||||
NextAppSpec,
|
||||
CreateNextAppOptions,
|
||||
NextAppExports,
|
||||
PageData,
|
||||
} from "./types";
|
||||
import { matchRoute, slugToPath, collectStaticParams } from "./router";
|
||||
import { resolveMetadata, type ResolvedMetadata } from "./metadata";
|
||||
|
||||
/**
|
||||
* Resolve the spec from the options — supports both static specs
|
||||
* and factory functions that return specs (sync or async).
|
||||
*/
|
||||
async function resolveSpec(
|
||||
specOrFn: NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>),
|
||||
): Promise<NextAppSpec> {
|
||||
if (typeof specOrFn === "function") {
|
||||
return await specOrFn();
|
||||
}
|
||||
return specOrFn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge state from multiple sources: global state, page state, and loader data.
|
||||
* Later sources override earlier ones.
|
||||
*/
|
||||
function mergeState(
|
||||
...sources: (Record<string, unknown> | undefined | null)[]
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const source of sources) {
|
||||
if (source) {
|
||||
Object.assign(result, source);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fully wired Next.js application from a json-render spec.
|
||||
*
|
||||
* Returns `getPageData`, `generateMetadata`, and `generateStaticParams`
|
||||
* ready to be used in Next.js `[[...slug]]` catch-all routes.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // lib/app.ts
|
||||
* import { createNextApp } from "@json-render/next/server";
|
||||
*
|
||||
* export const { getPageData, generateMetadata, generateStaticParams } =
|
||||
* createNextApp({ spec });
|
||||
*
|
||||
* // app/[[...slug]]/page.tsx (Server Component)
|
||||
* import { notFound } from "next/navigation";
|
||||
* import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
|
||||
* import { SiteRenderer } from "./renderer";
|
||||
*
|
||||
* export { generateMetadata, generateStaticParams };
|
||||
*
|
||||
* export default async function Page({ params }) {
|
||||
* const data = await getPageData({ params });
|
||||
* if (!data) notFound();
|
||||
* return <SiteRenderer {...data} />;
|
||||
* }
|
||||
*
|
||||
* // app/[[...slug]]/renderer.tsx ("use client")
|
||||
* import { PageRenderer } from "@json-render/next";
|
||||
* export function SiteRenderer(props) {
|
||||
* return <PageRenderer {...props} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function createNextApp(options: CreateNextAppOptions): NextAppExports {
|
||||
const { spec: specOrFn, loaders } = options;
|
||||
|
||||
/**
|
||||
* Resolve page data for a given set of route params.
|
||||
* Returns null when no route matches.
|
||||
*/
|
||||
async function getPageData({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<PageData | null> {
|
||||
const { slug } = await params;
|
||||
const pathname = slugToPath(slug);
|
||||
const spec = await resolveSpec(specOrFn);
|
||||
|
||||
const matched = matchRoute(spec, pathname);
|
||||
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { route } = matched;
|
||||
|
||||
let loaderData: Record<string, unknown> | undefined;
|
||||
if (route.loader && loaders?.[route.loader]) {
|
||||
loaderData = await loaders[route.loader]!(matched.params);
|
||||
}
|
||||
|
||||
const initialState = mergeState(
|
||||
spec.state,
|
||||
route.page.state as Record<string, unknown> | undefined,
|
||||
loaderData,
|
||||
);
|
||||
|
||||
const layoutSpec =
|
||||
route.layout && spec.layouts
|
||||
? (spec.layouts[route.layout] ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
spec: route.page,
|
||||
initialState:
|
||||
Object.keys(initialState).length > 0 ? initialState : undefined,
|
||||
layoutSpec,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata for the matched route.
|
||||
* Merges global spec metadata with route-specific metadata.
|
||||
*/
|
||||
async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}): Promise<ResolvedMetadata> {
|
||||
const { slug } = await params;
|
||||
const pathname = slugToPath(slug);
|
||||
const spec = await resolveSpec(specOrFn);
|
||||
const matched = matchRoute(spec, pathname);
|
||||
return resolveMetadata(spec, matched?.route ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static params for all statically-known routes.
|
||||
* Used by Next.js to pre-render pages at build time.
|
||||
*/
|
||||
async function generateStaticParams(): Promise<{ slug: string[] }[]> {
|
||||
const spec = await resolveSpec(specOrFn);
|
||||
return collectStaticParams(spec);
|
||||
}
|
||||
|
||||
return { getPageData, generateMetadata, generateStaticParams };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
// =============================================================================
|
||||
// @json-render/next — Client exports
|
||||
//
|
||||
// This entry point includes client components and is safe to import
|
||||
// in "use client" modules and Next.js pages/layouts.
|
||||
// =============================================================================
|
||||
|
||||
// Provider
|
||||
export {
|
||||
NextAppProvider,
|
||||
useNextApp,
|
||||
type NextAppProviderProps,
|
||||
type NextAppContextValue,
|
||||
} from "./components/provider";
|
||||
|
||||
// Page renderer
|
||||
export {
|
||||
PageRenderer,
|
||||
type PageRendererProps,
|
||||
} from "./components/page-renderer";
|
||||
|
||||
// Error boundary
|
||||
export {
|
||||
NextErrorBoundary,
|
||||
type NextErrorBoundaryProps,
|
||||
} from "./components/error-boundary";
|
||||
|
||||
// Loading
|
||||
export {
|
||||
NextLoading,
|
||||
type NextLoadingProps,
|
||||
} from "./components/loading-renderer";
|
||||
|
||||
// Not found
|
||||
export { NextNotFound } from "./components/not-found-renderer";
|
||||
|
||||
// Link component
|
||||
export { Link, type LinkProps } from "./components/link";
|
||||
|
||||
// Types (re-exported for convenience)
|
||||
export type {
|
||||
NextAppSpec,
|
||||
NextRouteSpec,
|
||||
NextMetadata,
|
||||
MatchedRoute,
|
||||
LoaderFn,
|
||||
CreateNextAppOptions,
|
||||
NextAppExports,
|
||||
PageData,
|
||||
} from "./types";
|
||||
|
||||
// Catalog types (re-exported from @json-render/react)
|
||||
export type {
|
||||
EventHandle,
|
||||
BaseComponentProps,
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "./catalog-types";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
export { createStateStore } from "@json-render/core";
|
||||
export type {
|
||||
ComponentRegistry,
|
||||
ComponentRenderProps,
|
||||
} from "@json-render/react";
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { NextMetadata, NextAppSpec, NextRouteSpec } from "./types";
|
||||
|
||||
/**
|
||||
* Metadata result compatible with Next.js's Metadata type.
|
||||
* Uses plain objects to avoid importing Next.js types at runtime.
|
||||
*/
|
||||
export type ResolvedMetadata = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Resolve metadata for a route by merging global spec metadata
|
||||
* with route-specific metadata.
|
||||
*
|
||||
* Follows Next.js metadata merging semantics:
|
||||
* - Route metadata overrides global metadata for scalar fields
|
||||
* - Title templates from global metadata apply to route titles
|
||||
* - OpenGraph and Twitter fields are shallow-merged
|
||||
*/
|
||||
export function resolveMetadata(
|
||||
spec: NextAppSpec,
|
||||
route?: NextRouteSpec | null,
|
||||
): ResolvedMetadata {
|
||||
const globalMeta = spec.metadata;
|
||||
const routeMeta = route?.metadata;
|
||||
|
||||
if (!globalMeta && !routeMeta) return {};
|
||||
|
||||
const result: ResolvedMetadata = {};
|
||||
|
||||
const mergedTitle = resolveTitle(globalMeta?.title, routeMeta?.title);
|
||||
if (mergedTitle !== undefined) result.title = mergedTitle;
|
||||
|
||||
const description = routeMeta?.description ?? globalMeta?.description;
|
||||
if (description) result.description = description;
|
||||
|
||||
const keywords = routeMeta?.keywords ?? globalMeta?.keywords;
|
||||
if (keywords) result.keywords = keywords;
|
||||
|
||||
const openGraph = mergeObject(globalMeta?.openGraph, routeMeta?.openGraph);
|
||||
if (openGraph) result.openGraph = openGraph;
|
||||
|
||||
const twitter = mergeObject(globalMeta?.twitter, routeMeta?.twitter);
|
||||
if (twitter) result.twitter = twitter;
|
||||
|
||||
const robots = routeMeta?.robots ?? globalMeta?.robots;
|
||||
if (robots) result.robots = robots;
|
||||
|
||||
const alternates = routeMeta?.alternates ?? globalMeta?.alternates;
|
||||
if (alternates) result.alternates = alternates;
|
||||
|
||||
const icons = routeMeta?.icons ?? globalMeta?.icons;
|
||||
if (icons) result.icons = icons;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a title value considering templates.
|
||||
*
|
||||
* If global metadata defines a title template (e.g. "%s | My App"),
|
||||
* route titles are interpolated into it.
|
||||
*/
|
||||
function resolveTitle(
|
||||
globalTitle: NextMetadata["title"],
|
||||
routeTitle: NextMetadata["title"],
|
||||
): unknown {
|
||||
if (!routeTitle && !globalTitle) return undefined;
|
||||
|
||||
if (!routeTitle) {
|
||||
if (typeof globalTitle === "string") return globalTitle;
|
||||
if (typeof globalTitle === "object" && globalTitle !== null) {
|
||||
return globalTitle.default ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof routeTitle === "object" && routeTitle !== null) {
|
||||
if (routeTitle.absolute) return routeTitle.absolute;
|
||||
if (routeTitle.template || routeTitle.default) return routeTitle;
|
||||
return routeTitle;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof globalTitle === "object" &&
|
||||
globalTitle !== null &&
|
||||
globalTitle.template
|
||||
) {
|
||||
return globalTitle.template.replace("%s", routeTitle as string);
|
||||
}
|
||||
|
||||
return routeTitle;
|
||||
}
|
||||
|
||||
function mergeObject(
|
||||
base: Record<string, unknown> | undefined,
|
||||
override: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
if (!base) return override;
|
||||
if (!override) return base;
|
||||
return { ...base, ...override };
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
"use client";
|
||||
|
||||
export { PageRenderer } from "./components/page-renderer";
|
||||
export type { PageRendererProps } from "./components/page-renderer";
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { NextAppSpec, MatchedRoute } from "./types";
|
||||
|
||||
interface CompiledRoute {
|
||||
pattern: string;
|
||||
regex: RegExp;
|
||||
paramNames: string[];
|
||||
/** Whether the last segment is catch-all or optional catch-all */
|
||||
catchAll: boolean;
|
||||
optionalCatchAll: boolean;
|
||||
/** Number of static segments (higher = more specific) */
|
||||
specificity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a Next.js route pattern into a regex matcher.
|
||||
*
|
||||
* Supports:
|
||||
* - Static segments: `/about`, `/blog`
|
||||
* - Dynamic segments: `/blog/[slug]`
|
||||
* - Catch-all segments: `/docs/[...path]`
|
||||
* - Optional catch-all segments: `/settings/[[...path]]`
|
||||
*/
|
||||
function compileRoute(pattern: string): CompiledRoute {
|
||||
const paramNames: string[] = [];
|
||||
let catchAll = false;
|
||||
let optionalCatchAll = false;
|
||||
let specificity = 0;
|
||||
|
||||
const segments = pattern === "/" ? [""] : pattern.split("/").slice(1);
|
||||
const regexParts: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
|
||||
const paramName = segment.slice(5, -2);
|
||||
paramNames.push(paramName);
|
||||
optionalCatchAll = true;
|
||||
regexParts.push("(?:/(.+))?");
|
||||
} else if (segment.startsWith("[...") && segment.endsWith("]")) {
|
||||
const paramName = segment.slice(4, -1);
|
||||
paramNames.push(paramName);
|
||||
catchAll = true;
|
||||
regexParts.push("/(.+)");
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
const paramName = segment.slice(1, -1);
|
||||
paramNames.push(paramName);
|
||||
regexParts.push("/([^/]+)");
|
||||
} else {
|
||||
specificity++;
|
||||
regexParts.push(`/${escapeRegExp(segment)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const regexStr = pattern === "/" ? "^/$" : `^${regexParts.join("")}$`;
|
||||
|
||||
return {
|
||||
pattern,
|
||||
regex: new RegExp(regexStr),
|
||||
paramNames,
|
||||
catchAll,
|
||||
optionalCatchAll,
|
||||
specificity,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a pathname against a spec's routes.
|
||||
*
|
||||
* Routes are matched in order of specificity:
|
||||
* 1. Exact/static matches first (most specific)
|
||||
* 2. Dynamic segment matches
|
||||
* 3. Catch-all matches (least specific)
|
||||
* 4. Optional catch-all matches (fallback)
|
||||
*
|
||||
* @returns The matched route with extracted params, or null if no match.
|
||||
*/
|
||||
export function matchRoute(
|
||||
spec: NextAppSpec,
|
||||
pathname: string,
|
||||
): MatchedRoute | null {
|
||||
const normalizedPath = pathname === "" ? "/" : pathname;
|
||||
|
||||
const compiled = Object.keys(spec.routes).map(compileRoute);
|
||||
|
||||
compiled.sort((a, b) => {
|
||||
if (a.optionalCatchAll !== b.optionalCatchAll) {
|
||||
return a.optionalCatchAll ? 1 : -1;
|
||||
}
|
||||
if (a.catchAll !== b.catchAll) {
|
||||
return a.catchAll ? 1 : -1;
|
||||
}
|
||||
if (a.specificity !== b.specificity) {
|
||||
return b.specificity - a.specificity;
|
||||
}
|
||||
return a.paramNames.length - b.paramNames.length;
|
||||
});
|
||||
|
||||
for (const route of compiled) {
|
||||
const match = route.regex.exec(normalizedPath);
|
||||
if (!match) continue;
|
||||
|
||||
const params: Record<string, string | string[]> = {};
|
||||
for (let i = 0; i < route.paramNames.length; i++) {
|
||||
const value = match[i + 1];
|
||||
const name = route.paramNames[i]!;
|
||||
|
||||
if (route.catchAll || route.optionalCatchAll) {
|
||||
params[name] = value ? value.split("/") : [];
|
||||
} else {
|
||||
params[name] = value ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
route: spec.routes[route.pattern]!,
|
||||
pattern: route.pattern,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Next.js catch-all slug array to a pathname.
|
||||
*
|
||||
* @example
|
||||
* slugToPath(undefined) // "/"
|
||||
* slugToPath([]) // "/"
|
||||
* slugToPath(["blog"]) // "/blog"
|
||||
* slugToPath(["blog","hi"]) // "/blog/hi"
|
||||
*/
|
||||
export function slugToPath(slug: string[] | undefined): string {
|
||||
if (!slug || slug.length === 0) return "/";
|
||||
return "/" + slug.join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all static params from the spec for generateStaticParams.
|
||||
* Returns params suitable for Next.js [[...slug]] catch-all routes.
|
||||
*/
|
||||
export function collectStaticParams(spec: NextAppSpec): { slug: string[] }[] {
|
||||
const results: { slug: string[] }[] = [];
|
||||
|
||||
for (const [pattern, route] of Object.entries(spec.routes)) {
|
||||
if (route.staticParams) {
|
||||
for (const paramSet of route.staticParams) {
|
||||
const slug = buildSlugFromPattern(pattern, paramSet);
|
||||
if (slug) results.push({ slug });
|
||||
}
|
||||
} else if (!pattern.includes("[")) {
|
||||
const slug = pattern === "/" ? [] : pattern.slice(1).split("/");
|
||||
results.push({ slug });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a slug array from a route pattern and a set of params.
|
||||
*/
|
||||
function buildSlugFromPattern(
|
||||
pattern: string,
|
||||
params: Record<string, string>,
|
||||
): string[] | null {
|
||||
if (pattern === "/") return [];
|
||||
|
||||
const segments = pattern.split("/").slice(1);
|
||||
const result: string[] = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.startsWith("[[...") && segment.endsWith("]]")) {
|
||||
const paramName = segment.slice(5, -2);
|
||||
const value = params[paramName];
|
||||
if (value) result.push(...value.split("/"));
|
||||
} else if (segment.startsWith("[...") && segment.endsWith("]")) {
|
||||
const paramName = segment.slice(4, -1);
|
||||
const value = params[paramName];
|
||||
if (!value) return null;
|
||||
result.push(...value.split("/"));
|
||||
} else if (segment.startsWith("[") && segment.endsWith("]")) {
|
||||
const paramName = segment.slice(1, -1);
|
||||
const value = params[paramName];
|
||||
if (!value) return null;
|
||||
result.push(value);
|
||||
} else {
|
||||
result.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { defineSchema, type PromptContext } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Prompt template for Next.js app generation.
|
||||
*
|
||||
* Teaches the AI to generate multi-page applications with routes,
|
||||
* layouts, metadata, and page content using JSONL patches.
|
||||
*/
|
||||
function nextAppPromptTemplate(context: PromptContext): string {
|
||||
const { catalog, options, formatZodType } = context;
|
||||
const {
|
||||
system = "You are a Next.js application generator.",
|
||||
customRules = [],
|
||||
} = options;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(system);
|
||||
lines.push("");
|
||||
|
||||
lines.push("OUTPUT FORMAT:");
|
||||
lines.push(
|
||||
"Output JSONL (one JSON object per line) with RFC 6902 JSON Patch operations to build a Next.js application spec.",
|
||||
);
|
||||
lines.push(
|
||||
"The spec defines routes (pages), layouts, metadata, and state for a full Next.js app.",
|
||||
);
|
||||
lines.push("");
|
||||
lines.push("Example output (each line is a separate JSON object):");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
`{"op":"add","path":"/metadata","value":{"title":{"default":"My App","template":"%s | My App"},"description":"A full Next.js application"}}`,
|
||||
);
|
||||
lines.push(`{"op":"add","path":"/layouts","value":{}}`);
|
||||
lines.push(
|
||||
`{"op":"add","path":"/layouts/main","value":{"root":"shell","elements":{"shell":{"type":"AppShell","props":{},"children":["nav","slot"]},"nav":{"type":"NavBar","props":{"links":[{"href":"/","label":"Home"},{"href":"/about","label":"About"}]},"children":[]},"slot":{"type":"Slot","props":{},"children":[]}}}}`,
|
||||
);
|
||||
lines.push(`{"op":"add","path":"/routes","value":{}}`);
|
||||
lines.push(
|
||||
`{"op":"add","path":"/routes/~1","value":{"layout":"main","metadata":{"title":"Home"},"page":{"root":"hero","elements":{"hero":{"type":"Card","props":{"title":"Welcome"},"children":[]}}}}}`,
|
||||
);
|
||||
lines.push(
|
||||
`{"op":"add","path":"/routes/~1about","value":{"layout":"main","metadata":{"title":"About"},"page":{"root":"content","elements":{"content":{"type":"Card","props":{"title":"About Us"},"children":[]}}}}}`,
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("SPEC STRUCTURE:");
|
||||
lines.push("The top-level spec has these fields:");
|
||||
lines.push(
|
||||
" - metadata: Root-level SEO metadata (title template, description, openGraph, twitter)",
|
||||
);
|
||||
lines.push(
|
||||
" - layouts: Reusable layout element trees. Each layout MUST include a { type: 'Slot' } element where page content will be injected.",
|
||||
);
|
||||
lines.push(" - routes: Route definitions keyed by URL pattern");
|
||||
lines.push(" - state: Global initial state shared across all routes");
|
||||
lines.push("");
|
||||
|
||||
lines.push("ROUTES:");
|
||||
lines.push("Route keys use Next.js URL patterns:");
|
||||
lines.push(" - '/' - home page");
|
||||
lines.push(" - '/about' - static route");
|
||||
lines.push(" - '/blog/[slug]' - dynamic segment");
|
||||
lines.push(" - '/docs/[...path]' - catch-all segment");
|
||||
lines.push(" - '/settings/[[...path]]' - optional catch-all segment");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"IMPORTANT: In JSON Patch paths, forward slashes in route keys must be escaped as ~1.",
|
||||
);
|
||||
lines.push(" - Route '/' becomes path '/routes/~1'");
|
||||
lines.push(" - Route '/about' becomes path '/routes/~1about'");
|
||||
lines.push(" - Route '/blog/[slug]' becomes path '/routes/~1blog~1[slug]'");
|
||||
lines.push("");
|
||||
|
||||
lines.push("Each route has:");
|
||||
lines.push(
|
||||
" - page: Element tree (root + elements + optional state) — the page content",
|
||||
);
|
||||
lines.push(
|
||||
" - metadata: Per-route SEO metadata (title, description, openGraph)",
|
||||
);
|
||||
lines.push(" - layout: Layout key referencing an entry in the layouts map");
|
||||
lines.push(" - loading: Optional loading state element tree");
|
||||
lines.push(" - error: Optional error state element tree");
|
||||
lines.push(" - loader: Optional server-side data loader name");
|
||||
lines.push("");
|
||||
|
||||
lines.push("LAYOUTS:");
|
||||
lines.push(
|
||||
"Layouts wrap page content. A layout element tree MUST include a component with type 'Slot' — this is where the page content will be rendered.",
|
||||
);
|
||||
lines.push(
|
||||
"Layouts are defined once and reused across routes via the layout field.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
lines.push("METADATA:");
|
||||
lines.push("Root metadata sets defaults. Route metadata overrides per page.");
|
||||
lines.push(
|
||||
" - title: string or { default, template } (use %s for page title in template)",
|
||||
);
|
||||
lines.push(" - description: string");
|
||||
lines.push(" - openGraph: { title, description, images, type }");
|
||||
lines.push(" - twitter: { card, title, description }");
|
||||
lines.push("");
|
||||
|
||||
lines.push("PAGE CONTENT:");
|
||||
lines.push(
|
||||
"Each page uses the standard json-render element tree format: root, elements, optional state.",
|
||||
);
|
||||
lines.push(
|
||||
"Elements have type, props, children, and optionally visible, on, repeat, watch fields.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
const catalogData = catalog as {
|
||||
components?: Record<
|
||||
string,
|
||||
{
|
||||
description?: string;
|
||||
props?: unknown;
|
||||
slots?: string[];
|
||||
events?: string[];
|
||||
}
|
||||
>;
|
||||
actions?: Record<string, { description?: string }>;
|
||||
};
|
||||
|
||||
if (catalogData.components) {
|
||||
lines.push(
|
||||
`AVAILABLE COMPONENTS (${Object.keys(catalogData.components).length}):`,
|
||||
);
|
||||
lines.push("");
|
||||
for (const [name, def] of Object.entries(catalogData.components)) {
|
||||
const propsStr = def.props ? formatZodType(def.props as any) : "{}";
|
||||
const hasChildren = def.slots && def.slots.length > 0;
|
||||
const childrenStr = hasChildren ? " [accepts children]" : "";
|
||||
const eventsStr =
|
||||
def.events && def.events.length > 0
|
||||
? ` [events: ${def.events.join(", ")}]`
|
||||
: "";
|
||||
const descStr = def.description ? ` - ${def.description}` : "";
|
||||
lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push("Built-in components (always available):");
|
||||
lines.push(
|
||||
"- Slot: {} - Placeholder in layouts where page content is rendered. Required in every layout.",
|
||||
);
|
||||
lines.push(
|
||||
"- Link: { href: string } [accepts children] - Client-side navigation link (renders as next/link).",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (catalogData.actions && Object.keys(catalogData.actions).length > 0) {
|
||||
lines.push("AVAILABLE ACTIONS:");
|
||||
lines.push("");
|
||||
for (const [name, def] of Object.entries(catalogData.actions)) {
|
||||
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push("BUILT-IN ACTIONS:");
|
||||
lines.push(
|
||||
"- setState: Update a value in the state model. Params: { statePath: string, value: any }",
|
||||
);
|
||||
lines.push(
|
||||
"- pushState: Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }",
|
||||
);
|
||||
lines.push(
|
||||
"- removeState: Remove an item from an array by index. Params: { statePath: string, index: number }",
|
||||
);
|
||||
lines.push("- navigate: Navigate to a route. Params: { href: string }");
|
||||
lines.push("");
|
||||
|
||||
lines.push("RULES:");
|
||||
const baseRules = [
|
||||
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
||||
"First add /metadata with the app-level metadata including title template",
|
||||
"Then add /layouts with reusable layout definitions (each must have a Slot component)",
|
||||
"Then add /routes with each route's page, metadata, and layout reference",
|
||||
"Every layout MUST include a { type: 'Slot' } element where page content is injected",
|
||||
"ONLY use components listed in AVAILABLE COMPONENTS (plus built-in Slot and Link)",
|
||||
"Each element needs: type, props, children (array of child keys)",
|
||||
"Use unique keys for element map entries",
|
||||
"Escape forward slashes in route keys as ~1 in JSON Patch paths",
|
||||
"Include realistic sample data in state for data-driven pages",
|
||||
"Use Link component for navigation between routes",
|
||||
"Create a cohesive multi-page app with consistent layouts and navigation",
|
||||
];
|
||||
const allRules = [...baseRules, ...customRules];
|
||||
allRules.forEach((rule, i) => {
|
||||
lines.push(`${i + 1}. ${rule}`);
|
||||
});
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* The schema for @json-render/next.
|
||||
*
|
||||
* Defines a multi-page Next.js application structure:
|
||||
* - Spec: Routes with pages, layouts, metadata, loading/error states
|
||||
* - Catalog: Components with props schemas, and optional actions
|
||||
*
|
||||
* This schema is fundamentally different from the React element tree schema.
|
||||
* It's page-based, designed for full Next.js applications with SSR,
|
||||
* routing, and metadata generation.
|
||||
*/
|
||||
export const schema = defineSchema(
|
||||
(s) => ({
|
||||
spec: s.object({
|
||||
/** Root-level metadata applied as defaults */
|
||||
metadata: s.object({
|
||||
title: s.any(),
|
||||
description: s.string(),
|
||||
keywords: s.array(s.string()),
|
||||
openGraph: s.any(),
|
||||
twitter: s.any(),
|
||||
robots: s.any(),
|
||||
icons: s.any(),
|
||||
}),
|
||||
|
||||
/** Route definitions keyed by URL pattern */
|
||||
routes: s.record(
|
||||
s.object({
|
||||
/** Page element tree */
|
||||
page: 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(),
|
||||
}),
|
||||
),
|
||||
state: s.any(),
|
||||
}),
|
||||
/** Per-route metadata */
|
||||
metadata: s.any(),
|
||||
/** Layout key */
|
||||
layout: s.string(),
|
||||
/** Loading state element tree */
|
||||
loading: s.any(),
|
||||
/** Error state element tree */
|
||||
error: s.any(),
|
||||
/** Not-found element tree */
|
||||
notFound: s.any(),
|
||||
/** Server-side data loader name */
|
||||
loader: s.string(),
|
||||
/** Static params for SSG */
|
||||
staticParams: s.any(),
|
||||
}),
|
||||
),
|
||||
|
||||
/** Reusable layout element trees */
|
||||
layouts: s.record(
|
||||
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(),
|
||||
}),
|
||||
),
|
||||
state: s.any(),
|
||||
}),
|
||||
),
|
||||
|
||||
/** Global initial state */
|
||||
state: s.any(),
|
||||
}),
|
||||
|
||||
catalog: s.object({
|
||||
/** Component definitions */
|
||||
components: s.map({
|
||||
props: s.zod(),
|
||||
slots: s.array(s.string()),
|
||||
description: s.string(),
|
||||
example: s.any(),
|
||||
}),
|
||||
/** Action definitions (optional) */
|
||||
actions: s.map({
|
||||
params: s.zod(),
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
promptTemplate: nextAppPromptTemplate,
|
||||
builtInActions: [
|
||||
{
|
||||
name: "setState",
|
||||
description:
|
||||
"Update a value in the state model at the given statePath. Params: { statePath: string, value: any }",
|
||||
},
|
||||
{
|
||||
name: "pushState",
|
||||
description:
|
||||
'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.',
|
||||
},
|
||||
{
|
||||
name: "removeState",
|
||||
description:
|
||||
"Remove an item from an array in state by index. Params: { statePath: string, index: number }",
|
||||
},
|
||||
{
|
||||
name: "navigate",
|
||||
description:
|
||||
"Navigate to a route within the app. Params: { href: string }",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Type for the Next.js schema
|
||||
*/
|
||||
export type NextSchema = typeof schema;
|
||||
|
||||
/**
|
||||
* Infer the spec type from a catalog
|
||||
*/
|
||||
export type NextSpec<TCatalog> = typeof schema extends {
|
||||
createCatalog: (catalog: TCatalog) => { _specType: infer S };
|
||||
}
|
||||
? S
|
||||
: never;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @json-render/next/server — Server-safe exports
|
||||
*
|
||||
* This entry point does not import any client components or React hooks.
|
||||
* Safe to use in:
|
||||
* - Server Components
|
||||
* - API routes
|
||||
* - Build scripts
|
||||
* - Server Actions
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createNextApp, schema } from "@json-render/next/server";
|
||||
* ```
|
||||
*/
|
||||
|
||||
// createNextApp (main API)
|
||||
export { createNextApp } from "./create-app";
|
||||
|
||||
// Schema (Next.js app spec format)
|
||||
export { schema, type NextSchema, type NextSpec } from "./schema";
|
||||
|
||||
// Router utilities
|
||||
export { matchRoute, slugToPath, collectStaticParams } from "./router";
|
||||
|
||||
// Metadata resolution
|
||||
export { resolveMetadata, type ResolvedMetadata } from "./metadata";
|
||||
|
||||
// Types
|
||||
export type {
|
||||
NextAppSpec,
|
||||
NextRouteSpec,
|
||||
NextMetadata,
|
||||
MatchedRoute,
|
||||
LoaderFn,
|
||||
CreateNextAppOptions,
|
||||
NextAppExports,
|
||||
PageData,
|
||||
} from "./types";
|
||||
|
||||
// Catalog types (type-only, no runtime)
|
||||
export type {
|
||||
EventHandle,
|
||||
BaseComponentProps,
|
||||
SetState,
|
||||
StateModel,
|
||||
ComponentContext,
|
||||
ComponentFn,
|
||||
Components,
|
||||
ActionFn,
|
||||
Actions,
|
||||
} from "./catalog-types";
|
||||
|
||||
// Core types (re-exported for convenience)
|
||||
export type { Spec, StateStore } from "@json-render/core";
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
|
||||
/**
|
||||
* Metadata for Next.js pages and layouts.
|
||||
* Maps to Next.js's Metadata type.
|
||||
*/
|
||||
export interface NextMetadata {
|
||||
/** Page title — string or template config */
|
||||
title?:
|
||||
| string
|
||||
| {
|
||||
/** Default title when no child overrides */
|
||||
default: string;
|
||||
/** Template string, use %s for the page title (e.g. "%s | My App") */
|
||||
template?: string;
|
||||
/** Absolute title that ignores parent templates */
|
||||
absolute?: string;
|
||||
};
|
||||
/** Meta description */
|
||||
description?: string;
|
||||
/** Keywords for SEO */
|
||||
keywords?: string[];
|
||||
/** Open Graph metadata */
|
||||
openGraph?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
images?: string | string[];
|
||||
type?: string;
|
||||
url?: string;
|
||||
siteName?: string;
|
||||
locale?: string;
|
||||
};
|
||||
/** Twitter card metadata */
|
||||
twitter?: {
|
||||
card?: "summary" | "summary_large_image" | "app" | "player";
|
||||
title?: string;
|
||||
description?: string;
|
||||
images?: string | string[];
|
||||
creator?: string;
|
||||
site?: string;
|
||||
};
|
||||
/** Robots directives */
|
||||
robots?: string | { index?: boolean; follow?: boolean };
|
||||
/** Canonical URL */
|
||||
alternates?: {
|
||||
canonical?: string;
|
||||
};
|
||||
/** Favicon and icons */
|
||||
icons?: string | { icon?: string; apple?: string; shortcut?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* A route definition within a NextAppSpec.
|
||||
*/
|
||||
export interface NextRouteSpec {
|
||||
/** Page content — standard json-render element tree */
|
||||
page: Spec;
|
||||
/** Per-route metadata for SEO */
|
||||
metadata?: NextMetadata;
|
||||
/** Layout key referencing an entry in NextAppSpec.layouts */
|
||||
layout?: string;
|
||||
/** Loading state element tree (rendered during Suspense) */
|
||||
loading?: Spec;
|
||||
/** Error state element tree */
|
||||
error?: Spec;
|
||||
/** Not-found element tree (for dynamic routes that don't match data) */
|
||||
notFound?: Spec;
|
||||
/** Server-side data loader name (references loaders in createNextApp) */
|
||||
loader?: string;
|
||||
/** Static params for generateStaticParams */
|
||||
staticParams?: Record<string, string>[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The full application spec for @json-render/next.
|
||||
*
|
||||
* Describes an entire Next.js application: routes, pages, layouts,
|
||||
* metadata, and server-side data loading.
|
||||
*
|
||||
* Routes are keyed by URL patterns using Next.js conventions:
|
||||
* - `"/"` — exact match
|
||||
* - `"/blog/[slug]"` — dynamic segment
|
||||
* - `"/docs/[...path]"` — catch-all segment
|
||||
* - `"/settings/[[...path]]"` — optional catch-all segment
|
||||
*/
|
||||
export interface NextAppSpec {
|
||||
/** Root-level metadata applied to all routes (layouts can override) */
|
||||
metadata?: NextMetadata;
|
||||
/** Route definitions keyed by URL pattern */
|
||||
routes: Record<string, NextRouteSpec>;
|
||||
/**
|
||||
* Reusable layout element trees. Each layout spec must include a
|
||||
* `Slot` component type where page content will be injected.
|
||||
*/
|
||||
layouts?: Record<string, Spec>;
|
||||
/** Global initial state shared across all routes */
|
||||
state?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of matching a pathname against the spec's routes.
|
||||
*/
|
||||
export interface MatchedRoute {
|
||||
/** The matched route spec */
|
||||
route: NextRouteSpec;
|
||||
/** The URL pattern that matched (e.g. "/blog/[slug]") */
|
||||
pattern: string;
|
||||
/** Extracted route parameters (e.g. { slug: "hello-world" }) */
|
||||
params: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side data loader function signature.
|
||||
* Receives route params and returns data to merge into initial state.
|
||||
*/
|
||||
export type LoaderFn = (
|
||||
params: Record<string, string | string[]>,
|
||||
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Options for createNextApp.
|
||||
*/
|
||||
export interface CreateNextAppOptions {
|
||||
/** The application spec */
|
||||
spec: NextAppSpec | (() => NextAppSpec | Promise<NextAppSpec>);
|
||||
/** Server-side data loaders keyed by name */
|
||||
loaders?: Record<string, LoaderFn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data returned by getPageData for client-side rendering.
|
||||
*/
|
||||
export interface PageData {
|
||||
/** Page element tree spec */
|
||||
spec: Spec;
|
||||
/** Initial state (merged from spec.state, global state, and loader data) */
|
||||
initialState?: Record<string, unknown>;
|
||||
/** Optional layout element tree spec */
|
||||
layoutSpec?: Spec | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of createNextApp — exports for Next.js route files.
|
||||
*/
|
||||
export interface NextAppExports {
|
||||
/**
|
||||
* Resolve page data for a given set of params.
|
||||
* Returns null if no route matches (caller should call notFound()).
|
||||
*
|
||||
* Use this in your Server Component page.tsx alongside a client wrapper
|
||||
* that renders PageRenderer:
|
||||
*
|
||||
* ```tsx
|
||||
* // app/[[...slug]]/page.tsx
|
||||
* import { getPageData, generateMetadata, generateStaticParams } from "@/lib/app";
|
||||
* import { WebsiteRenderer } from "./renderer";
|
||||
*
|
||||
* export { generateMetadata, generateStaticParams };
|
||||
*
|
||||
* export default async function Page({ params }) {
|
||||
* const data = await getPageData({ params });
|
||||
* if (!data) notFound();
|
||||
* return <WebsiteRenderer {...data} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
getPageData: (props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) => Promise<PageData | null>;
|
||||
/** generateMetadata function for page.tsx */
|
||||
generateMetadata: (props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
/** generateStaticParams function for page.tsx */
|
||||
generateStaticParams: () => Promise<{ slug: string[] }[]>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
const sharedExternal = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"next",
|
||||
"next/link",
|
||||
"next/navigation",
|
||||
"@json-render/core",
|
||||
"@json-render/react",
|
||||
];
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
"page-renderer-client": "src/page-renderer-client.tsx",
|
||||
},
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
splitting: true,
|
||||
clean: true,
|
||||
banner: { js: '"use client";' },
|
||||
external: sharedExternal,
|
||||
},
|
||||
{
|
||||
entry: { server: "src/server.ts" },
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
splitting: false,
|
||||
external: [...sharedExternal, /page-renderer-client/],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,70 @@
|
||||
# @json-render/shadcn-svelte
|
||||
|
||||
Pre-built [shadcn-svelte](https://www.shadcn-svelte.com/) components for json-render. Drop-in catalog definitions and Svelte implementations for 36 components built on Svelte 5 + Tailwind CSS.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @json-render/shadcn-svelte @json-render/core @json-render/svelte zod
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Create a Catalog
|
||||
|
||||
```ts
|
||||
import { schema } from "@json-render/svelte/schema";
|
||||
import { shadcnComponentDefinitions } from "@json-render/shadcn-svelte/catalog";
|
||||
|
||||
const catalog = schema.createCatalog({
|
||||
components: {
|
||||
Card: shadcnComponentDefinitions.Card,
|
||||
Stack: shadcnComponentDefinitions.Stack,
|
||||
Heading: shadcnComponentDefinitions.Heading,
|
||||
Button: shadcnComponentDefinitions.Button,
|
||||
Input: shadcnComponentDefinitions.Input,
|
||||
},
|
||||
actions: {},
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Create a Registry
|
||||
|
||||
```ts
|
||||
import { defineRegistry } from "@json-render/svelte";
|
||||
import { shadcnComponents } from "@json-render/shadcn-svelte";
|
||||
|
||||
const { registry } = defineRegistry(catalog, {
|
||||
components: {
|
||||
Card: shadcnComponents.Card,
|
||||
Stack: shadcnComponents.Stack,
|
||||
Heading: shadcnComponents.Heading,
|
||||
Button: shadcnComponents.Button,
|
||||
Input: shadcnComponents.Input,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Render
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { Renderer, JsonUIProvider } from "@json-render/svelte";
|
||||
|
||||
export let spec;
|
||||
export let registry;
|
||||
</script>
|
||||
|
||||
<JsonUIProvider initialState={spec?.state ?? {}}>
|
||||
<Renderer {spec} {registry} />
|
||||
</JsonUIProvider>
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
| Entry Point | Exports |
|
||||
|-------------|---------|
|
||||
| `@json-render/shadcn-svelte` | `shadcnComponents`, `shadcnComponentDefinitions` |
|
||||
| `@json-render/shadcn-svelte/catalog` | `shadcnComponentDefinitions` |
|
||||
|
||||
The `/catalog` entrypoint contains only Zod schemas (no renderer dependency), so it can be used in server-side code for prompt generation.
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "neutral"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$ui",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry",
|
||||
"style": "vega",
|
||||
"iconLibrary": "lucide",
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle"
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "@json-render/shadcn-svelte",
|
||||
"version": "0.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"description": "shadcn-svelte component library for @json-render/svelte. JSON becomes beautiful Tailwind-styled Svelte components.",
|
||||
"keywords": [
|
||||
"json",
|
||||
"ui",
|
||||
"svelte",
|
||||
"svelte5",
|
||||
"shadcn-svelte",
|
||||
"tailwind",
|
||||
"bits-ui",
|
||||
"ai",
|
||||
"generative-ui",
|
||||
"llm",
|
||||
"renderer",
|
||||
"streaming",
|
||||
"components"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||
"directory": "packages/shadcn-svelte"
|
||||
},
|
||||
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"type": "module",
|
||||
"svelte": "./dist/index.js",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"svelte": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./catalog": {
|
||||
"types": "./dist/catalog.d.ts",
|
||||
"svelte": "./dist/catalog.js",
|
||||
"default": "./dist/catalog.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "svelte-package -i src -o dist",
|
||||
"dev": "svelte-package -i src -o dist --watch",
|
||||
"check-types": "svelte-check --tsconfig ./tsconfig.json",
|
||||
"typecheck": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@json-render/core": "workspace:*",
|
||||
"@json-render/svelte": "workspace:*",
|
||||
"@lucide/svelte": "^0.577.0",
|
||||
"bits-ui": "^2.16.3",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"vaul-svelte": "1.0.0-next.7",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@internal/typescript-config": "workspace:*",
|
||||
"@internationalized/date": "^3.12.0",
|
||||
"@sveltejs/package": "^2.5.7",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||
"svelte": "^5.54.1",
|
||||
"svelte-check": "^4.4.5",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import * as Accordion from "./ui/accordion/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Accordion">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const items = $derived(props.items ?? []);
|
||||
</script>
|
||||
|
||||
<Accordion.Root type={props.type ?? "single"} class="w-full">
|
||||
{#each items as item, i}
|
||||
<Accordion.Item value={`item-${i}`}>
|
||||
<Accordion.Trigger>{item.title}</Accordion.Trigger>
|
||||
<Accordion.Content>{item.content}</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Alert, AlertDescription, AlertTitle } from "./ui/alert/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Alert">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const variant = $derived(props.type === "error" ? "destructive" : "default");
|
||||
</script>
|
||||
|
||||
<Alert {variant}>
|
||||
<AlertTitle>{props.title}</AlertTitle>
|
||||
{#if props.message}
|
||||
<AlertDescription>{props.message}</AlertDescription>
|
||||
{/if}
|
||||
</Alert>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Avatar">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const initials = $derived(
|
||||
props.name
|
||||
.split(" ")
|
||||
.map((part) => part[0] ?? "")
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase(),
|
||||
);
|
||||
|
||||
const sizeClass = $derived(
|
||||
props.size === "sm" ? "size-8 text-xs" : props.size === "lg" ? "size-12 text-base" : "size-10 text-sm",
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={`inline-flex items-center justify-center overflow-hidden rounded-full bg-muted text-muted-foreground ${sizeClass}`}>
|
||||
{#if props.src}
|
||||
<img src={props.src} alt={props.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<span>{initials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Badge } from "./ui/badge/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Badge">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Button } from "./ui/button/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Button">> {}
|
||||
|
||||
let { props, emit }: Props = $props();
|
||||
|
||||
const rawVariant = $derived((props.variant ?? "primary") as string);
|
||||
const variant = $derived(
|
||||
rawVariant === "danger" || rawVariant === "destructive"
|
||||
? "destructive"
|
||||
: rawVariant === "secondary" || rawVariant === "outline" || rawVariant === "ghost"
|
||||
? "secondary"
|
||||
: "default",
|
||||
);
|
||||
</script>
|
||||
|
||||
<Button {variant} disabled={props.disabled ?? false} onclick={() => emit("press")}>
|
||||
{props.label}
|
||||
</Button>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"ButtonGroup">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.selected ?? undefined,
|
||||
() => bindings?.selected,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function choose(next: string) {
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-flex rounded-md border border-border">
|
||||
{#each props.buttons ?? [] as btn, i}
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1.5 text-sm ${value === btn.value ? "bg-primary text-primary-foreground" : "bg-background hover:bg-muted"} ${i > 0 ? "border-l border-border" : ""}`}
|
||||
onclick={() => choose(btn.value)}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import * as Card from "./ui/card/index.js";
|
||||
import { cn } from "./lib/utils.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Card">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
const maxWidthClass = $derived(
|
||||
props.maxWidth === "sm"
|
||||
? "max-w-xs sm:min-w-[280px]"
|
||||
: props.maxWidth === "md"
|
||||
? "max-w-sm sm:min-w-[320px]"
|
||||
: props.maxWidth === "lg"
|
||||
? "max-w-md sm:min-w-[360px]"
|
||||
: "w-full",
|
||||
);
|
||||
</script>
|
||||
|
||||
<Card.Root class={cn(maxWidthClass, props.centered ? "mx-auto" : "") }>
|
||||
{#if props.title || props.description}
|
||||
<Card.Header>
|
||||
{#if props.title}
|
||||
<Card.Title>{props.title}</Card.Title>
|
||||
{/if}
|
||||
{#if props.description}
|
||||
<Card.Description>{props.description}</Card.Description>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
{/if}
|
||||
<Card.Content class="flex flex-col gap-3">
|
||||
{@render children?.()}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "./ui/carousel/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Carousel">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const items = $derived(props.items ?? []);
|
||||
</script>
|
||||
|
||||
<div class="relative px-10">
|
||||
<Carousel>
|
||||
<CarouselContent>
|
||||
{#each items as item}
|
||||
<CarouselItem class="basis-64">
|
||||
<div class="rounded-lg border bg-card p-4">
|
||||
{#if item.title}
|
||||
<h4 class="font-medium">{item.title}</h4>
|
||||
{/if}
|
||||
{#if item.description}
|
||||
<p class="text-sm text-muted-foreground">{item.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</CarouselItem>
|
||||
{/each}
|
||||
</CarouselContent>
|
||||
<CarouselPrevious />
|
||||
<CarouselNext />
|
||||
</Carousel>
|
||||
</div>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
import { Checkbox } from "./ui/checkbox/index.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Checkbox">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localChecked = $state(untrack(() => Boolean(props.checked)));
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "change") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.checked),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<boolean>(
|
||||
() => props.checked ?? undefined,
|
||||
() => bindings?.checked,
|
||||
);
|
||||
|
||||
const checked = $derived(bound.current ?? localChecked);
|
||||
|
||||
function handleChange(value: boolean | "indeterminate") {
|
||||
const next = value === true;
|
||||
localChecked = next;
|
||||
bound.current = next;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={props.name}
|
||||
{checked}
|
||||
onCheckedChange={handleChange}
|
||||
/>
|
||||
<Label for={props.name} class="text-sm cursor-pointer">{props.label}</Label>
|
||||
</div>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "./ui/collapsible/index.js";
|
||||
import { Button } from "./ui/button/index.js";
|
||||
import ChevronDown from "@lucide/svelte/icons/chevron-down";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Collapsible">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
let open = $state(untrack(() => props.defaultOpen ?? false));
|
||||
</script>
|
||||
|
||||
<Collapsible bind:open class="w-full rounded-md border border-border">
|
||||
<CollapsibleTrigger>
|
||||
{#snippet child({ props: triggerProps })}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm font-medium"
|
||||
{...triggerProps}
|
||||
>
|
||||
<span>{props.title}</span>
|
||||
<ChevronDown class={`size-4 transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div class="px-4 pb-3 pt-1">{@render children?.()}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { getStateValue } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Dialog">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
const openState = getStateValue(untrack(() => props.openPath));
|
||||
const open = $derived(Boolean(openState.current));
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
openState.current = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.title}</DialogTitle>
|
||||
{#if props.description}
|
||||
<DialogDescription>{props.description}</DialogDescription>
|
||||
{/if}
|
||||
</DialogHeader>
|
||||
{@render children?.()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { getStateValue } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "./ui/drawer/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Drawer">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
const openState = getStateValue(untrack(() => props.openPath));
|
||||
const open = $derived(Boolean(openState.current));
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
openState.current = next;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Drawer {open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{props.title}</DrawerTitle>
|
||||
{#if props.description}
|
||||
<DrawerDescription>{props.description}</DrawerDescription>
|
||||
{/if}
|
||||
</DrawerHeader>
|
||||
<div class="px-4 pb-4">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu/index.js";
|
||||
import { Button } from "./ui/button/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"DropdownMenu">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
const valueBound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
function select(value: string) {
|
||||
valueBound.current = value;
|
||||
emit("select");
|
||||
}
|
||||
</script>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
{#snippet child({ props: triggerProps })}
|
||||
<Button variant="outline" {...triggerProps}>{props.label}</Button>
|
||||
{/snippet}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{#each props.items ?? [] as item}
|
||||
<DropdownMenuItem onclick={() => select(item.value)}>
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
{/each}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Grid">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
const colsMap: Record<number, string> = {
|
||||
1: "grid-cols-1",
|
||||
2: "grid-cols-2",
|
||||
3: "grid-cols-3",
|
||||
4: "grid-cols-4",
|
||||
5: "grid-cols-5",
|
||||
6: "grid-cols-6",
|
||||
};
|
||||
|
||||
const gapMap: Record<string, string> = {
|
||||
sm: "gap-2",
|
||||
md: "gap-3",
|
||||
lg: "gap-4",
|
||||
};
|
||||
|
||||
const n = $derived(Math.max(1, Math.min(6, props.columns ?? 1)));
|
||||
</script>
|
||||
|
||||
<div class={`grid ${colsMap[n] ?? "grid-cols-1"} ${gapMap[props.gap ?? "md"] ?? "gap-3"}`}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Heading">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const level = $derived(props.level ?? "h2");
|
||||
const className = $derived(
|
||||
level === "h1"
|
||||
? "text-3xl font-bold tracking-tight"
|
||||
: level === "h2"
|
||||
? "text-2xl font-semibold tracking-tight"
|
||||
: level === "h3"
|
||||
? "text-xl font-semibold"
|
||||
: "text-lg font-medium",
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:element this={level} class={className}>{props.text}</svelte:element>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Image">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if props.src}
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.alt}
|
||||
width={props.width ?? undefined}
|
||||
height={props.height ?? undefined}
|
||||
class="max-w-full rounded-md border border-border object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-center justify-center rounded-md border border-dashed border-border bg-muted text-sm text-muted-foreground"
|
||||
style={`width:${props.width ?? 320}px;height:${props.height ?? 180}px`}
|
||||
>
|
||||
{props.alt}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Input } from "./ui/input/index.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Input">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "blur") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.value),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const next = (event.target as HTMLInputElement).value;
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (validateOn === "blur") errors = validation.run(validateOn);
|
||||
emit("blur");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for={props.name}>{props.label}</Label>
|
||||
<Input
|
||||
id={props.name}
|
||||
name={props.name}
|
||||
type={props.type ?? "text"}
|
||||
placeholder={props.placeholder ?? ""}
|
||||
{value}
|
||||
oninput={handleInput}
|
||||
onfocus={() => emit("focus")}
|
||||
onblur={handleBlur}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") emit("submit");
|
||||
}}
|
||||
/>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Link">> {}
|
||||
|
||||
let { props, on }: Props = $props();
|
||||
|
||||
function handleClick(event: MouseEvent) {
|
||||
const press = on("press");
|
||||
if (press.shouldPreventDefault) event.preventDefault();
|
||||
press.emit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
href={props.href ?? "#"}
|
||||
class="text-primary text-sm font-medium underline-offset-4 hover:underline"
|
||||
onclick={handleClick}
|
||||
>
|
||||
{props.label}
|
||||
</a>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { getPaginationRange } from "./helpers.js";
|
||||
import { Button } from "./ui/button/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Pagination">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
const pageBound = getBoundProp<number>(
|
||||
() => props.page ?? undefined,
|
||||
() => bindings?.page,
|
||||
);
|
||||
|
||||
let localPage = $state(untrack(() => props.page ?? 1));
|
||||
const page = $derived(pageBound.current ?? localPage);
|
||||
const totalPages = $derived(props.totalPages ?? 1);
|
||||
const pages = $derived(getPaginationRange(page, totalPages));
|
||||
|
||||
function setPage(next: number) {
|
||||
if (next < 1 || next > totalPages || next === page) return;
|
||||
localPage = next;
|
||||
pageBound.current = next;
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" onclick={() => setPage(page - 1)} disabled={page <= 1}>Prev</Button>
|
||||
{#each pages as p}
|
||||
{#if p === "ellipsis"}
|
||||
<span class="px-2 text-sm text-muted-foreground">…</span>
|
||||
{:else}
|
||||
<Button
|
||||
variant={p === page ? "default" : "outline"}
|
||||
size="sm"
|
||||
onclick={() => setPage(p)}
|
||||
>
|
||||
{p}
|
||||
</Button>
|
||||
{/if}
|
||||
{/each}
|
||||
<Button variant="outline" size="sm" onclick={() => setPage(page + 1)} disabled={page >= totalPages}>Next</Button>
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "./ui/popover/index.js";
|
||||
import { Button } from "./ui/button/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Popover">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
{#snippet child({ props: triggerProps })}
|
||||
<Button variant="outline" {...triggerProps}>{props.trigger}</Button>
|
||||
{/snippet}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent class="w-64 text-sm">{props.content}</PopoverContent>
|
||||
</Popover>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Progress } from "./ui/progress/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Progress">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const max = $derived(props.max ?? 100);
|
||||
const percent = $derived(Math.max(0, Math.min(100, (props.value / max) * 100)));
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if props.label}
|
||||
<div class="text-sm text-muted-foreground">{props.label}</div>
|
||||
{/if}
|
||||
<Progress value={percent} />
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group/index.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Radio">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "change") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.value),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function choose(next: string) {
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if props.label}
|
||||
<span class="text-sm font-medium">{props.label}</span>
|
||||
{/if}
|
||||
<RadioGroup {value} onValueChange={choose} name={props.name}>
|
||||
{#each props.options ?? [] as option, i}
|
||||
{@const id = `${props.name}-${i}`}
|
||||
<div class="flex items-center gap-2">
|
||||
<RadioGroupItem value={option} {id} />
|
||||
<Label for={id} class="text-sm cursor-pointer">{option}</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</RadioGroup>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import * as Select from "./ui/select/index.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Select">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "change") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.value),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function handleValueChange(next: string | undefined) {
|
||||
if (!next) return;
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{props.label}</Label>
|
||||
<Select.Root type="single" {value} onValueChange={handleValueChange}>
|
||||
<Select.Trigger>
|
||||
{value || props.placeholder || "Select..."}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each props.options ?? [] as option, i}
|
||||
<Select.Item value={option || `option-${i}`}>{option}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Separator } from "./ui/separator/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Separator">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Separator
|
||||
orientation={props.orientation ?? "horizontal"}
|
||||
class={props.orientation === "vertical" ? "h-full mx-2" : "my-3"}
|
||||
/>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Skeleton">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`animate-pulse bg-muted ${props.rounded ? "rounded-full" : "rounded-md"}`}
|
||||
style={`width:${props.width ?? "100%"};height:${props.height ?? "1rem"};`}
|
||||
></div>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Slider } from "./ui/slider/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Slider">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state(untrack(() => props.value ?? props.min ?? 0));
|
||||
|
||||
const bound = getBoundProp<number>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function handleChange(next: number) {
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#if props.label}
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span>{props.label}</span>
|
||||
<span class="text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<Slider
|
||||
type="single"
|
||||
value={value}
|
||||
min={props.min ?? 0}
|
||||
max={props.max ?? 100}
|
||||
step={props.step ?? 1}
|
||||
onValueChange={handleChange}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Spinner">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const sizeClass = $derived(
|
||||
props.size === "sm" ? "size-4" : props.size === "lg" ? "size-8" : "size-6",
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="inline-flex items-center gap-2 text-muted-foreground">
|
||||
<div class={`animate-spin rounded-full border-2 border-current border-t-transparent ${sizeClass}`}></div>
|
||||
{#if props.label}
|
||||
<span class="text-sm">{props.label}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Stack">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children }: Props = $props();
|
||||
|
||||
const gapMap: Record<string, string> = {
|
||||
none: "gap-0",
|
||||
sm: "gap-2",
|
||||
md: "gap-3",
|
||||
lg: "gap-4",
|
||||
};
|
||||
|
||||
const alignMap: Record<string, string> = {
|
||||
start: "items-start",
|
||||
center: "items-center",
|
||||
end: "items-end",
|
||||
stretch: "items-stretch",
|
||||
};
|
||||
|
||||
const justifyMap: Record<string, string> = {
|
||||
start: "",
|
||||
center: "justify-center",
|
||||
end: "justify-end",
|
||||
between: "justify-between",
|
||||
around: "justify-around",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`flex ${props.direction === "horizontal" ? "flex-row flex-wrap" : "flex-col"} ${gapMap[props.gap ?? "md"] ?? "gap-3"} ${alignMap[props.align ?? "start"] ?? "items-start"} ${justifyMap[props.justify ?? "start"] ?? ""}`}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
import { Switch } from "./ui/switch/index.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Switch">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localChecked = $state(untrack(() => Boolean(props.checked)));
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "change") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.checked),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<boolean>(
|
||||
() => props.checked ?? undefined,
|
||||
() => bindings?.checked,
|
||||
);
|
||||
|
||||
const checked = $derived(bound.current ?? localChecked);
|
||||
|
||||
function handleChange(value: boolean) {
|
||||
localChecked = value;
|
||||
bound.current = value;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id={props.name} {checked} onCheckedChange={handleChange} />
|
||||
{#if props.label}
|
||||
<Label for={props.name} class="text-sm cursor-pointer">{props.label}</Label>
|
||||
{/if}
|
||||
</div>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import * as Table from "./ui/table/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Table">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const columns = $derived(props.columns ?? []);
|
||||
const rows = $derived((props.rows ?? []).map((row) => row.map((cell) => String(cell))));
|
||||
</script>
|
||||
|
||||
<div class="rounded-md border overflow-hidden">
|
||||
<Table.Root>
|
||||
{#if props.caption}
|
||||
<Table.Caption>{props.caption}</Table.Caption>
|
||||
{/if}
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each columns as col}
|
||||
<Table.Head>{col}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each rows as row}
|
||||
<Table.Row>
|
||||
{#each row as cell}
|
||||
<Table.Cell>{cell}</Table.Cell>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import * as Tabs from "./ui/tabs/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Tabs">> {
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { props, children, bindings, emit }: Props = $props();
|
||||
|
||||
const tabs = $derived(props.tabs ?? []);
|
||||
let localValue = $state(untrack(() => props.defaultValue ?? tabs[0]?.value ?? ""));
|
||||
let activeValue = $state(untrack(() => localValue));
|
||||
let previousValue = $state(untrack(() => activeValue));
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const current = bound.current ?? localValue ?? tabs[0]?.value ?? "";
|
||||
if (current !== activeValue) {
|
||||
activeValue = current;
|
||||
previousValue = current;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (activeValue === previousValue) return;
|
||||
previousValue = activeValue;
|
||||
localValue = activeValue;
|
||||
bound.current = activeValue;
|
||||
emit("change");
|
||||
});
|
||||
</script>
|
||||
|
||||
<Tabs.Root bind:value={activeValue}>
|
||||
<Tabs.List>
|
||||
{#each tabs as tab}
|
||||
<Tabs.Trigger value={tab.value}>{tab.label}</Tabs.Trigger>
|
||||
{/each}
|
||||
</Tabs.List>
|
||||
{@render children?.()}
|
||||
</Tabs.Root>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Text">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
|
||||
const className = $derived(
|
||||
props.variant === "caption"
|
||||
? "text-xs text-muted-foreground"
|
||||
: props.variant === "muted"
|
||||
? "text-sm text-muted-foreground"
|
||||
: props.variant === "lead"
|
||||
? "text-lg text-foreground"
|
||||
: props.variant === "code"
|
||||
? "rounded bg-muted px-1 py-0.5 font-mono text-sm"
|
||||
: "text-sm text-foreground",
|
||||
);
|
||||
</script>
|
||||
|
||||
<p class={className}>{props.text}</p>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp, getOptionalValidationContext } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Label } from "./ui/label/index.js";
|
||||
import { createValidation } from "./helpers.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Textarea">> {}
|
||||
|
||||
let { props, bindings }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
let errors = $state<string[]>([]);
|
||||
|
||||
const validateOn = $derived((props.validateOn ?? "blur") as "change" | "blur" | "submit");
|
||||
const validationCtx = getOptionalValidationContext();
|
||||
const validation = createValidation(
|
||||
validationCtx,
|
||||
untrack(() => bindings?.value),
|
||||
untrack(() => props.checks ?? null),
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const on = validateOn;
|
||||
untrack(() => {
|
||||
if (validation.hasValidation) validation.register(on);
|
||||
});
|
||||
});
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const next = (event.target as HTMLTextAreaElement).value;
|
||||
localValue = next;
|
||||
bound.current = next;
|
||||
if (validateOn === "change") errors = validation.run(validateOn);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (validateOn === "blur") errors = validation.run(validateOn);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for={props.name}>{props.label}</Label>
|
||||
<textarea
|
||||
id={props.name}
|
||||
name={props.name}
|
||||
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex min-h-[80px] w-full rounded-md border px-3 py-2 text-sm outline-none focus-visible:ring-2"
|
||||
placeholder={props.placeholder ?? ""}
|
||||
rows={props.rows ?? 3}
|
||||
value={value}
|
||||
oninput={handleInput}
|
||||
onblur={handleBlur}
|
||||
></textarea>
|
||||
{#if errors.length > 0}
|
||||
<p class="text-sm text-destructive">{errors[0]}</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { Toggle } from "./ui/toggle/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Toggle">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localPressed = $state(untrack(() => props.pressed ?? false));
|
||||
|
||||
const bound = getBoundProp<boolean>(
|
||||
() => props.pressed ?? undefined,
|
||||
() => bindings?.pressed,
|
||||
);
|
||||
|
||||
const pressed = $derived(bound.current ?? localPressed);
|
||||
|
||||
function handleChange(next: boolean) {
|
||||
localPressed = next;
|
||||
bound.current = next;
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
<Toggle {pressed} onPressedChange={handleChange}>{props.label}</Toggle>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { getBoundProp } from "@json-render/svelte";
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"ToggleGroup">> {}
|
||||
|
||||
let { props, bindings, emit }: Props = $props();
|
||||
|
||||
let localValue = $state("");
|
||||
|
||||
const bound = getBoundProp<string>(
|
||||
() => props.value ?? undefined,
|
||||
() => bindings?.value,
|
||||
);
|
||||
|
||||
const value = $derived(bound.current ?? localValue);
|
||||
const isMultiple = $derived((props.type ?? "single") === "multiple");
|
||||
const selectedValues = $derived(value ? value.split(",").filter(Boolean) : []);
|
||||
|
||||
function handleChange(next: string | string[] | undefined) {
|
||||
const serialized = Array.isArray(next) ? next.join(",") : (next ?? "");
|
||||
localValue = serialized;
|
||||
bound.current = serialized;
|
||||
emit("change");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isMultiple}
|
||||
<ToggleGroup
|
||||
type="multiple"
|
||||
value={selectedValues}
|
||||
onValueChange={handleChange}
|
||||
variant="outline"
|
||||
>
|
||||
{#each props.items ?? [] as item}
|
||||
<ToggleGroupItem value={item.value}>{item.label}</ToggleGroupItem>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
{:else}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={selectedValues[0] ?? ""}
|
||||
onValueChange={handleChange}
|
||||
variant="outline"
|
||||
>
|
||||
{#each props.items ?? [] as item}
|
||||
<ToggleGroupItem value={item.value}>{item.label}</ToggleGroupItem>
|
||||
{/each}
|
||||
</ToggleGroup>
|
||||
{/if}
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { BaseComponentProps } from "@json-render/svelte";
|
||||
import type { ShadcnProps } from "./catalog.js";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./ui/tooltip/index.js";
|
||||
|
||||
interface Props extends BaseComponentProps<ShadcnProps<"Tooltip">> {}
|
||||
|
||||
let { props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger class="cursor-help text-sm underline decoration-dotted">
|
||||
{props.text}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{props.content}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -0,0 +1,69 @@
|
||||
@import "tw-animate-css";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-open {
|
||||
&:where([data-state="open"]), &:where([data-open]:not([data-open="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-closed {
|
||||
&:where([data-state="closed"]), &:where([data-closed]:not([data-closed="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-checked {
|
||||
&:where([data-state="checked"]), &:where([data-checked]:not([data-checked="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-unchecked {
|
||||
&:where([data-state="unchecked"]), &:where([data-unchecked]:not([data-unchecked="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-selected {
|
||||
&:where([data-selected]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-disabled {
|
||||
&:where([data-disabled="true"]), &:where([data-disabled]:not([data-disabled="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-active {
|
||||
&:where([data-state="active"]), &:where([data-active]:not([data-active="false"])) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-horizontal {
|
||||
&:where([data-orientation="horizontal"]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@custom-variant data-vertical {
|
||||
&:where([data-orientation="vertical"]) {
|
||||
@slot;
|
||||
}
|
||||
}
|
||||
|
||||
@utility no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// =============================================================================
|
||||
// Shared validation schemas used across form components
|
||||
// =============================================================================
|
||||
|
||||
const validationCheckSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
message: z.string(),
|
||||
args: z.record(z.string(), z.unknown()).optional(),
|
||||
}),
|
||||
)
|
||||
.nullable();
|
||||
|
||||
const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
|
||||
|
||||
// =============================================================================
|
||||
// shadcn/ui Component Definitions
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* shadcn/ui component definitions for json-render catalogs.
|
||||
*
|
||||
* These can be used directly or extended with custom components.
|
||||
* All components are built using Radix UI primitives + Tailwind CSS.
|
||||
*/
|
||||
export const shadcnComponentDefinitions = {
|
||||
// ==========================================================================
|
||||
// Layout Components
|
||||
// ==========================================================================
|
||||
|
||||
Card: {
|
||||
props: z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
maxWidth: z.enum(["sm", "md", "lg", "full"]).nullable(),
|
||||
centered: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
|
||||
example: { title: "Overview", description: "Your account summary" },
|
||||
},
|
||||
|
||||
Stack: {
|
||||
props: z.object({
|
||||
direction: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
gap: z.enum(["none", "sm", "md", "lg"]).nullable(),
|
||||
align: z.enum(["start", "center", "end", "stretch"]).nullable(),
|
||||
justify: z
|
||||
.enum(["start", "center", "end", "between", "around"])
|
||||
.nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Flex container for layouts",
|
||||
example: { direction: "vertical", gap: "md" },
|
||||
},
|
||||
|
||||
Grid: {
|
||||
props: z.object({
|
||||
columns: z.number().nullable(),
|
||||
gap: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Grid layout (1-6 columns)",
|
||||
example: { columns: 3, gap: "md" },
|
||||
},
|
||||
|
||||
Separator: {
|
||||
props: z.object({
|
||||
orientation: z.enum(["horizontal", "vertical"]).nullable(),
|
||||
}),
|
||||
description: "Visual separator line",
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
props: z.object({
|
||||
tabs: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
defaultValue: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
events: ["change"],
|
||||
description:
|
||||
"Tab navigation. Use { $bindState } on value for active tab binding.",
|
||||
},
|
||||
|
||||
Accordion: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
}),
|
||||
description:
|
||||
"Collapsible sections. Items as [{title, content}]. Type 'single' (default) or 'multiple'.",
|
||||
},
|
||||
|
||||
Collapsible: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
defaultOpen: z.boolean().nullable(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description: "Collapsible section with trigger. Children render inside.",
|
||||
},
|
||||
|
||||
Dialog: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
openPath: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Modal dialog. Set openPath to a boolean state path. Use setState to toggle.",
|
||||
},
|
||||
|
||||
Drawer: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
openPath: z.string(),
|
||||
}),
|
||||
slots: ["default"],
|
||||
description:
|
||||
"Bottom sheet drawer. Set openPath to a boolean state path. Use setState to toggle.",
|
||||
},
|
||||
|
||||
Carousel: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
title: z.string().nullable(),
|
||||
description: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
description: "Horizontally scrollable carousel of cards.",
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Data Display Components
|
||||
// ==========================================================================
|
||||
|
||||
Table: {
|
||||
props: z.object({
|
||||
columns: z.array(z.string()),
|
||||
rows: z.array(z.array(z.string())),
|
||||
caption: z.string().nullable(),
|
||||
}),
|
||||
description:
|
||||
'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
|
||||
example: {
|
||||
columns: ["Name", "Role"],
|
||||
rows: [
|
||||
["Alice", "Admin"],
|
||||
["Bob", "User"],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
Heading: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
|
||||
}),
|
||||
description: "Heading text (h1-h4)",
|
||||
example: { text: "Welcome", level: "h1" },
|
||||
},
|
||||
|
||||
Text: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z.enum(["body", "caption", "muted", "lead", "code"]).nullable(),
|
||||
}),
|
||||
description: "Paragraph text",
|
||||
example: { text: "Hello, world!" },
|
||||
},
|
||||
|
||||
Image: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
alt: z.string(),
|
||||
width: z.number().nullable(),
|
||||
height: z.number().nullable(),
|
||||
}),
|
||||
description:
|
||||
"Image component. Renders an img tag when src is provided, otherwise a placeholder.",
|
||||
},
|
||||
|
||||
Avatar: {
|
||||
props: z.object({
|
||||
src: z.string().nullable(),
|
||||
name: z.string(),
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
}),
|
||||
description: "User avatar with fallback initials",
|
||||
example: { name: "Jane Doe", size: "md" },
|
||||
},
|
||||
|
||||
Badge: {
|
||||
props: z.object({
|
||||
text: z.string(),
|
||||
variant: z
|
||||
.enum(["default", "secondary", "destructive", "outline"])
|
||||
.nullable(),
|
||||
}),
|
||||
description: "Status badge",
|
||||
example: { text: "Active", variant: "default" },
|
||||
},
|
||||
|
||||
Alert: {
|
||||
props: z.object({
|
||||
title: z.string(),
|
||||
message: z.string().nullable(),
|
||||
type: z.enum(["info", "success", "warning", "error"]).nullable(),
|
||||
}),
|
||||
description: "Alert banner",
|
||||
example: {
|
||||
title: "Note",
|
||||
message: "Your changes have been saved.",
|
||||
type: "success",
|
||||
},
|
||||
},
|
||||
|
||||
Progress: {
|
||||
props: z.object({
|
||||
value: z.number(),
|
||||
max: z.number().nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Progress bar (value 0-100)",
|
||||
example: { value: 65, max: 100, label: "Upload progress" },
|
||||
},
|
||||
|
||||
Skeleton: {
|
||||
props: z.object({
|
||||
width: z.string().nullable(),
|
||||
height: z.string().nullable(),
|
||||
rounded: z.boolean().nullable(),
|
||||
}),
|
||||
description: "Loading placeholder skeleton",
|
||||
},
|
||||
|
||||
Spinner: {
|
||||
props: z.object({
|
||||
size: z.enum(["sm", "md", "lg"]).nullable(),
|
||||
label: z.string().nullable(),
|
||||
}),
|
||||
description: "Loading spinner indicator",
|
||||
},
|
||||
|
||||
Tooltip: {
|
||||
props: z.object({
|
||||
content: z.string(),
|
||||
text: z.string(),
|
||||
}),
|
||||
description: "Hover tooltip. Shows content on hover over text.",
|
||||
},
|
||||
|
||||
Popover: {
|
||||
props: z.object({
|
||||
trigger: z.string(),
|
||||
content: z.string(),
|
||||
}),
|
||||
description: "Popover that appears on click of trigger.",
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Form Input Components
|
||||
// ==========================================================================
|
||||
|
||||
Input: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(["text", "email", "password", "number"]).nullable(),
|
||||
placeholder: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["submit", "focus", "blur"],
|
||||
description:
|
||||
"Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
|
||||
example: {
|
||||
label: "Email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
placeholder: "you@example.com",
|
||||
},
|
||||
},
|
||||
|
||||
Textarea: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
placeholder: z.string().nullable(),
|
||||
rows: z.number().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
description:
|
||||
"Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
|
||||
},
|
||||
|
||||
Select: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
placeholder: z.string().nullable(),
|
||||
value: z.string().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Checkbox: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Radio: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
options: z.array(z.string()),
|
||||
value: z.string().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Switch: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
name: z.string(),
|
||||
checked: z.boolean().nullable(),
|
||||
checks: validationCheckSchema,
|
||||
validateOn: validateOnSchema,
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
|
||||
},
|
||||
|
||||
Slider: {
|
||||
props: z.object({
|
||||
label: z.string().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
step: z.number().nullable(),
|
||||
value: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description: "Range slider input. Use { $bindState } on value for binding.",
|
||||
},
|
||||
|
||||
// ==========================================================================
|
||||
// Action Components
|
||||
// ==========================================================================
|
||||
|
||||
Button: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
variant: z.enum(["primary", "secondary", "danger"]).nullable(),
|
||||
disabled: z.boolean().nullable(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Clickable button. Bind on.press for handler.",
|
||||
example: { label: "Submit", variant: "primary" },
|
||||
},
|
||||
|
||||
Link: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
href: z.string(),
|
||||
}),
|
||||
events: ["press"],
|
||||
description: "Anchor link. Bind on.press for click handler.",
|
||||
},
|
||||
|
||||
DropdownMenu: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["select"],
|
||||
description:
|
||||
"Dropdown menu with trigger button and selectable items. Use { $bindState } on value for selected item binding.",
|
||||
},
|
||||
|
||||
Toggle: {
|
||||
props: z.object({
|
||||
label: z.string(),
|
||||
pressed: z.boolean().nullable(),
|
||||
variant: z.enum(["default", "outline"]).nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Toggle button. Use { $bindState } on pressed for state binding.",
|
||||
},
|
||||
|
||||
ToggleGroup: {
|
||||
props: z.object({
|
||||
items: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
type: z.enum(["single", "multiple"]).nullable(),
|
||||
value: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Group of toggle buttons. Type 'single' (default) or 'multiple'. Use { $bindState } on value.",
|
||||
},
|
||||
|
||||
ButtonGroup: {
|
||||
props: z.object({
|
||||
buttons: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
}),
|
||||
),
|
||||
selected: z.string().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Segmented button group. Use { $bindState } on selected for selected value.",
|
||||
},
|
||||
|
||||
Pagination: {
|
||||
props: z.object({
|
||||
totalPages: z.number(),
|
||||
page: z.number().nullable(),
|
||||
}),
|
||||
events: ["change"],
|
||||
description:
|
||||
"Page navigation. Use { $bindState } on page for current page number.",
|
||||
},
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type for a component definition
|
||||
*/
|
||||
export type ComponentDefinition = {
|
||||
props: z.ZodType;
|
||||
slots?: string[];
|
||||
events?: string[];
|
||||
description: string;
|
||||
example?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Infer the props type for a shadcn component by name.
|
||||
* Derives the TypeScript type directly from the Zod schema,
|
||||
* so component implementations stay in sync with catalog definitions.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type CardProps = ShadcnProps<"Card">;
|
||||
* // { title: string | null; description: string | null; ... }
|
||||
* ```
|
||||
*/
|
||||
export type ShadcnProps<K extends keyof typeof shadcnComponentDefinitions> =
|
||||
z.output<(typeof shadcnComponentDefinitions)[K]["props"]>;
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Component } from "svelte";
|
||||
import Card from "./Card.svelte";
|
||||
import Stack from "./Stack.svelte";
|
||||
import Grid from "./Grid.svelte";
|
||||
import Separator from "./Separator.svelte";
|
||||
import Tabs from "./Tabs.svelte";
|
||||
import Accordion from "./Accordion.svelte";
|
||||
import Collapsible from "./Collapsible.svelte";
|
||||
import Dialog from "./Dialog.svelte";
|
||||
import Drawer from "./Drawer.svelte";
|
||||
import Carousel from "./Carousel.svelte";
|
||||
import Table from "./Table.svelte";
|
||||
import Heading from "./Heading.svelte";
|
||||
import Text from "./Text.svelte";
|
||||
import Image from "./Image.svelte";
|
||||
import Avatar from "./Avatar.svelte";
|
||||
import Badge from "./Badge.svelte";
|
||||
import Alert from "./Alert.svelte";
|
||||
import Progress from "./Progress.svelte";
|
||||
import Skeleton from "./Skeleton.svelte";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Tooltip from "./Tooltip.svelte";
|
||||
import Popover from "./Popover.svelte";
|
||||
import Input from "./Input.svelte";
|
||||
import Textarea from "./Textarea.svelte";
|
||||
import Select from "./Select.svelte";
|
||||
import Checkbox from "./Checkbox.svelte";
|
||||
import Radio from "./Radio.svelte";
|
||||
import Switch from "./Switch.svelte";
|
||||
import Slider from "./Slider.svelte";
|
||||
import Button from "./Button.svelte";
|
||||
import Link from "./Link.svelte";
|
||||
import DropdownMenu from "./DropdownMenu.svelte";
|
||||
import Toggle from "./Toggle.svelte";
|
||||
import ToggleGroup from "./ToggleGroup.svelte";
|
||||
import ButtonGroup from "./ButtonGroup.svelte";
|
||||
import Pagination from "./Pagination.svelte";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const shadcnComponents: Record<string, Component<any>> = {
|
||||
Card,
|
||||
Stack,
|
||||
Grid,
|
||||
Separator,
|
||||
Tabs,
|
||||
Accordion,
|
||||
Collapsible,
|
||||
Dialog,
|
||||
Drawer,
|
||||
Carousel,
|
||||
Table,
|
||||
Heading,
|
||||
Text,
|
||||
Image,
|
||||
Avatar,
|
||||
Badge,
|
||||
Alert,
|
||||
Progress,
|
||||
Skeleton,
|
||||
Spinner,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Input,
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
Radio,
|
||||
Switch,
|
||||
Slider,
|
||||
Button,
|
||||
Link,
|
||||
DropdownMenu,
|
||||
Toggle,
|
||||
ToggleGroup,
|
||||
ButtonGroup,
|
||||
Pagination,
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import { getOptionalValidationContext } from "@json-render/svelte";
|
||||
|
||||
type ValidateOn = "change" | "blur" | "submit";
|
||||
|
||||
type ValidationCheck = {
|
||||
type: string;
|
||||
message: string;
|
||||
args?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type ValidationCtx = ReturnType<typeof getOptionalValidationContext>;
|
||||
|
||||
export function getPaginationRange(
|
||||
current: number,
|
||||
total: number,
|
||||
): Array<number | "ellipsis"> {
|
||||
if (total <= 7) {
|
||||
return Array.from({ length: total }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
const pages: Array<number | "ellipsis"> = [1];
|
||||
if (current > 3) pages.push("ellipsis");
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(total - 1, current + 1);
|
||||
for (let i = start; i <= end; i += 1) pages.push(i);
|
||||
|
||||
if (current < total - 2) pages.push("ellipsis");
|
||||
pages.push(total);
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function createValidation(
|
||||
validation: ValidationCtx,
|
||||
path?: string,
|
||||
checks?: ValidationCheck[] | null,
|
||||
) {
|
||||
const config = checks && checks.length > 0 ? { checks } : null;
|
||||
|
||||
function register(validateOn: ValidateOn) {
|
||||
if (!validation || !path || !config) return;
|
||||
validation.registerField(path, {
|
||||
checks: config.checks,
|
||||
validateOn,
|
||||
});
|
||||
}
|
||||
|
||||
function run(validateOn: ValidateOn): string[] {
|
||||
if (!validation || !path || !config) return [];
|
||||
const result = validation.validate(path, {
|
||||
checks: config.checks,
|
||||
validateOn,
|
||||
});
|
||||
return result.errors;
|
||||
}
|
||||
|
||||
return {
|
||||
hasValidation: !!(validation && path && config),
|
||||
register,
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { shadcnComponents } from "./components.js";
|
||||
|
||||
export {
|
||||
shadcnComponentDefinitions,
|
||||
type ComponentDefinition,
|
||||
type ShadcnProps,
|
||||
} from "./catalog.js";
|
||||
@@ -0,0 +1,17 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type WithoutChildren<T> = T extends { children?: any }
|
||||
? Omit<T, "children">
|
||||
: T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
|
||||
ref?: U | null;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "../../lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
|
||||
{...restProps}
|
||||
>
|
||||
<div
|
||||
class={cn(
|
||||
"pt-0 pb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn("not-last:border-b", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChild } from "../../lib/utils.js";
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps["level"];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-md py-4 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" class="cn-accordion-trigger-icon pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from "bits-ui";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="accordion"
|
||||
class={cn("cn-accordion flex w-full flex-col", className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,16 @@
|
||||
import Root from "./accordion.svelte";
|
||||
import Content from "./accordion-content.svelte";
|
||||
import Item from "./accordion-item.svelte";
|
||||
import Trigger from "./accordion-trigger.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger,
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "../../lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-action"
|
||||
class={cn("absolute top-2.5 right-3", className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { cn, type WithElementRef } from "../../lib/utils.js";
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
"text-muted-foreground text-sm text-balance md:text-pretty [&_p:not(:last-child)]:mb-4 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user