mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-04 04:48:17 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3201854481 | ||
|
|
64c889221e | ||
|
|
49838fa353 | ||
|
|
fa47b08869 | ||
|
|
5ccb109c08 | ||
|
|
62932f6516 | ||
|
|
ee28d548c1 | ||
|
|
0e6f2afc6c | ||
|
|
bccedc2459 |
+2
-9
@@ -6,11 +6,8 @@ node_modules
|
|||||||
.pnp.js
|
.pnp.js
|
||||||
|
|
||||||
# Local env files
|
# Local env files
|
||||||
.env
|
.env*
|
||||||
.env.local
|
!.env.example
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
coverage
|
coverage
|
||||||
@@ -44,10 +41,6 @@ yarn-error.log*
|
|||||||
# opensrc - source code for packages
|
# opensrc - source code for packages
|
||||||
opensrc/
|
opensrc/
|
||||||
|
|
||||||
# json-studio (separate repo)
|
|
||||||
json-studio/
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# Stripe apps (generated from template + build artifacts)
|
# Stripe apps (generated from template + build artifacts)
|
||||||
examples/stripe-app/*/stripe-app.json
|
examples/stripe-app/*/stripe-app.json
|
||||||
examples/stripe-app/*/.build
|
examples/stripe-app/*/.build
|
||||||
|
|||||||
@@ -27,12 +27,48 @@ This ensures we don't install outdated versions that may have incompatible types
|
|||||||
- Do not use emojis in code or UI
|
- Do not use emojis in code or UI
|
||||||
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
- Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
|
||||||
|
|
||||||
|
## AI SDK / AI Gateway
|
||||||
|
|
||||||
|
When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { streamText } from "ai";
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: "anthropic/claude-haiku-4.5",
|
||||||
|
prompt: "...",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
|
||||||
|
|
||||||
|
## Dev Servers
|
||||||
|
|
||||||
|
All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
|
||||||
|
|
||||||
|
Naming convention:
|
||||||
|
- Main web app: `json-render` → `json-render.localhost:1355`
|
||||||
|
- Examples: `[name]-demo.json-render` → `[name]-demo.json-render.localhost:1355`
|
||||||
|
|
||||||
|
When adding a new example that runs a dev server, wrap its `dev` script with `portless <name>`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"dev": "portless my-example-demo.json-render next dev --turbopack"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do **not** add `--port` flags -- portless handles port assignment automatically. Do **not** add portless as a project dependency; it must be installed globally.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- Run `pnpm type-check` after each turn to ensure type safety
|
- Run `pnpm type-check` after each turn to ensure type safety
|
||||||
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
- When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
|
||||||
- Package `README.md` files in `packages/*/README.md`
|
- Package `README.md` files in `packages/*/README.md`
|
||||||
- Root `README.md` (if packages table, install commands, or examples are affected)
|
- Root `README.md` (if packages table, install commands, or examples are affected)
|
||||||
|
- Web app docs in `apps/web/` (if guides, API references, or examples need updating)
|
||||||
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
- Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
|
||||||
- `AGENTS.md` (if workflow or conventions change)
|
- `AGENTS.md` (if workflow or conventions change)
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,9 @@ function Dashboard({ spec }) {
|
|||||||
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
| `@json-render/react-native` | React Native renderer with standard mobile components |
|
||||||
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
| `@json-render/remotion` | Remotion video renderer, timeline schema |
|
||||||
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
| `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
|
||||||
|
| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
|
||||||
|
| `@json-render/zustand` | Zustand adapter for `StateStore` |
|
||||||
|
| `@json-render/jotai` | Jotai adapter for `StateStore` |
|
||||||
|
|
||||||
## Renderers
|
## Renderers
|
||||||
|
|
||||||
@@ -343,9 +346,9 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- http://localhost:3000 - Docs & Playground
|
- http://json-render.localhost:1355 - Docs & Playground
|
||||||
- http://localhost:3001 - Example Dashboard
|
- http://dashboard-demo.json-render.localhost:1355 - Example Dashboard
|
||||||
- http://localhost:3002 - Remotion Video Example
|
- http://remotion-demo.json-render.localhost:1355 - Remotion Video Example
|
||||||
- Chat Example: run `pnpm dev` in `examples/chat`
|
- Chat Example: run `pnpm dev` in `examples/chat`
|
||||||
- React Native example: run `npx expo start` in `examples/react-native`
|
- React Native example: run `npx expo start` in `examples/react-native`
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ pnpm dev
|
|||||||
bun dev
|
bun dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open [http://json-render.localhost:1355](http://json-render.localhost:1355) with your browser to see the result.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "A2UI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/a2ui")
|
||||||
|
|
||||||
# A2UI Integration
|
# A2UI Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Adaptive Cards Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/adaptive-cards")
|
||||||
|
|
||||||
# Adaptive Cards Integration
|
# Adaptive Cards Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "AG-UI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/ag-ui")
|
||||||
|
|
||||||
# AG-UI Integration
|
# AG-UI Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "AI SDK Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/ai-sdk")
|
||||||
|
|
||||||
# AI SDK Integration
|
# AI SDK Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/codegen API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/codegen")
|
||||||
|
|
||||||
# @json-render/codegen
|
# @json-render/codegen
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/core API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/core")
|
||||||
|
|
||||||
# @json-render/core
|
# @json-render/core
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/react-native API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-native")
|
||||||
|
|
||||||
# @json-render/react-native
|
# @json-render/react-native
|
||||||
|
|
||||||
@@ -62,11 +63,36 @@ React Native renderer with standard components, providers, and hooks.
|
|||||||
### StateProvider
|
### StateProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<StateProvider initialState={object}>
|
<StateProvider initialState={object} onStateChange={fn}>
|
||||||
{children}
|
{children}
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
|
||||||
|
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
|
||||||
|
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||||
|
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — components re-render automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||||
|
|
||||||
### ActionProvider
|
### ActionProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react-pdf")
|
||||||
|
|
||||||
|
# @json-render/react-pdf
|
||||||
|
|
||||||
|
PDF document renderer. Turn JSON specs into PDFs using `@react-pdf/renderer`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/core @json-render/react-pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [React PDF example](https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf) for a full working example.
|
||||||
|
|
||||||
|
## schema
|
||||||
|
|
||||||
|
The PDF element schema for document specs. Use with `defineCatalog` from core.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { defineCatalog } from '@json-render/core';
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const catalog = defineCatalog(schema, {
|
||||||
|
components: standardComponentDefinitions,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Render Functions
|
||||||
|
|
||||||
|
Server-side functions for producing PDF output. All accept a spec and optional `RenderOptions`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { renderToBuffer, renderToStream, renderToFile } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(spec);
|
||||||
|
|
||||||
|
const stream = await renderToStream(spec);
|
||||||
|
stream.pipe(res);
|
||||||
|
|
||||||
|
await renderToFile(spec, './output.pdf');
|
||||||
|
```
|
||||||
|
|
||||||
|
### RenderOptions
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RenderOptions {
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Option</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>registry</code></td>
|
||||||
|
<td>Custom component map (merged with standard components)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>includeStandard</code></td>
|
||||||
|
<td>Include built-in standard components (default: <code>true</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>state</code></td>
|
||||||
|
<td>Initial state for <code>$state</code> / <code>$cond</code> dynamic prop resolution</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## defineRegistry
|
||||||
|
|
||||||
|
Create a type-safe component registry from a catalog. Components receive `{ props, children, emit, bindings, loading }`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { defineRegistry } from '@json-render/react-pdf';
|
||||||
|
import { View, Text } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
const { registry } = defineRegistry(catalog, {
|
||||||
|
components: {
|
||||||
|
Badge: ({ props }) => (
|
||||||
|
<View style={{ backgroundColor: props.color ?? '#e5e7eb', padding: 4, borderRadius: 4 }}>
|
||||||
|
<Text style={{ fontSize: 10 }}>{props.label}</Text>
|
||||||
|
</View>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await renderToBuffer(spec, { registry });
|
||||||
|
```
|
||||||
|
|
||||||
|
## createRenderer
|
||||||
|
|
||||||
|
Create a standalone renderer component wired to state, actions, and validation.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createRenderer } from '@json-render/react-pdf';
|
||||||
|
|
||||||
|
const PDFRenderer = createRenderer(catalog, components);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface CreateRendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
store?: StateStore;
|
||||||
|
state?: Record<string, unknown>;
|
||||||
|
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: ComponentRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
|
||||||
|
|
||||||
|
## Renderer
|
||||||
|
|
||||||
|
The main component that renders a spec to `@react-pdf/renderer` elements.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface RendererProps {
|
||||||
|
spec: Spec | null;
|
||||||
|
registry?: ComponentRegistry;
|
||||||
|
includeStandard?: boolean; // default: true
|
||||||
|
loading?: boolean;
|
||||||
|
fallback?: ComponentRenderer;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Standard Components
|
||||||
|
|
||||||
|
### Document Structure
|
||||||
|
|
||||||
|
#### Document
|
||||||
|
|
||||||
|
Top-level PDF wrapper. Must be the root element. Children must be `Page` components.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
title: string | null;
|
||||||
|
author: string | null;
|
||||||
|
subject: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Page
|
||||||
|
|
||||||
|
A page in the document with configurable size, orientation, and margins.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
size: "A4" | "A3" | "A5" | "LETTER" | "LEGAL" | "TABLOID" | null;
|
||||||
|
orientation: "portrait" | "landscape" | null;
|
||||||
|
marginTop: number | null;
|
||||||
|
marginBottom: number | null;
|
||||||
|
marginLeft: number | null;
|
||||||
|
marginRight: number | null;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
|
||||||
|
#### View
|
||||||
|
|
||||||
|
Generic container with padding, margin, background, border, and flex alignment.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
padding: number | null;
|
||||||
|
paddingTop: number | null;
|
||||||
|
paddingBottom: number | null;
|
||||||
|
paddingLeft: number | null;
|
||||||
|
paddingRight: number | null;
|
||||||
|
margin: number | null;
|
||||||
|
backgroundColor: string | null;
|
||||||
|
borderWidth: number | null;
|
||||||
|
borderColor: string | null;
|
||||||
|
borderRadius: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Row
|
||||||
|
|
||||||
|
Horizontal flex layout with optional wrapping.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
wrap: boolean | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Column
|
||||||
|
|
||||||
|
Vertical flex layout.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
gap: number | null;
|
||||||
|
alignItems: "flex-start" | "center" | "flex-end" | "stretch" | null;
|
||||||
|
justifyContent: "flex-start" | "center" | "flex-end" | "space-between" | "space-around" | null;
|
||||||
|
padding: number | null;
|
||||||
|
flex: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Content
|
||||||
|
|
||||||
|
#### Heading
|
||||||
|
|
||||||
|
h1-h4 heading text with configurable color and alignment.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
level: "h1" | "h2" | "h3" | "h4" | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Text
|
||||||
|
|
||||||
|
Body text with full styling control.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
fontWeight: "normal" | "bold" | null;
|
||||||
|
fontStyle: "normal" | "italic" | null;
|
||||||
|
lineHeight: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Image
|
||||||
|
|
||||||
|
Image from a URL with optional dimensions and fit.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
src: string;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
objectFit: "contain" | "cover" | "fill" | "none" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Link
|
||||||
|
|
||||||
|
Hyperlink with visible text.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
text: string;
|
||||||
|
href: string;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data
|
||||||
|
|
||||||
|
#### Table
|
||||||
|
|
||||||
|
Data table with typed columns and string rows. Supports header styling and striped rows.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
columns: { header: string; width?: string; align?: "left" | "center" | "right" }[];
|
||||||
|
rows: string[][];
|
||||||
|
headerBackgroundColor: string | null;
|
||||||
|
headerTextColor: string | null;
|
||||||
|
borderColor: string | null;
|
||||||
|
fontSize: number | null;
|
||||||
|
striped: boolean | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### List
|
||||||
|
|
||||||
|
Ordered or unordered list.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
items: string[];
|
||||||
|
ordered: boolean | null;
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
spacing: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decorative
|
||||||
|
|
||||||
|
#### Divider
|
||||||
|
|
||||||
|
Horizontal line separator.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
color: string | null;
|
||||||
|
thickness: number | null;
|
||||||
|
marginTop: number | null;
|
||||||
|
marginBottom: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Spacer
|
||||||
|
|
||||||
|
Empty vertical space.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
height: number | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Page-Level
|
||||||
|
|
||||||
|
#### PageNumber
|
||||||
|
|
||||||
|
Renders current page number and total pages. Format uses `{pageNumber}` and `{totalPages}` placeholders.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
format: string | null; // default: "{pageNumber} / {totalPages}"
|
||||||
|
fontSize: number | null;
|
||||||
|
color: string | null;
|
||||||
|
align: "left" | "center" | "right" | null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||||
|
|
||||||
|
const store = createStateStore({ invoice: { total: 100 } });
|
||||||
|
store.set("/invoice/total", 200);
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
|
||||||
|
|
||||||
|
## Server-Safe Import
|
||||||
|
|
||||||
|
Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { schema, standardComponentDefinitions } from '@json-render/react-pdf/server';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sub-path Exports
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf</code></td>
|
||||||
|
<td>Full package: schema, renderer, components, render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/server</code></td>
|
||||||
|
<td>Schema and catalog definitions only (no React)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/catalog</code></td>
|
||||||
|
<td>Standard component definitions and types</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>@json-render/react-pdf/render</code></td>
|
||||||
|
<td>Server-side render functions only</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
## Types
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Export</th>
|
||||||
|
<th>Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactPdfSchema</code></td>
|
||||||
|
<td>Schema type for PDF specs</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ReactPdfSpec</code></td>
|
||||||
|
<td>Spec type for PDF documents</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>RenderOptions</code></td>
|
||||||
|
<td>Options for render functions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentContext</code></td>
|
||||||
|
<td>Typed component render function context</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>ComponentFn</code></td>
|
||||||
|
<td>Component render function type</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentDefinitions</code></td>
|
||||||
|
<td>Type of the standard component definitions object</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>StandardComponentProps<K></code></td>
|
||||||
|
<td>Inferred props type for a standard component by name</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/react API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/react")
|
||||||
|
|
||||||
# @json-render/react
|
# @json-render/react
|
||||||
|
|
||||||
@@ -9,11 +10,36 @@ React components, providers, and hooks.
|
|||||||
### StateProvider
|
### StateProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<StateProvider initialState={object}>
|
<StateProvider initialState={object} onStateChange={fn}>
|
||||||
{children}
|
{children}
|
||||||
</StateProvider>
|
</StateProvider>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Prop | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
|
||||||
|
| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
|
||||||
|
| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
|
||||||
|
|
||||||
|
#### External Store (Controlled Mode)
|
||||||
|
|
||||||
|
Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react";
|
||||||
|
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — React re-renders automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
|
||||||
|
|
||||||
### ActionProvider
|
### ActionProvider
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/remotion API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/remotion")
|
||||||
|
|
||||||
# @json-render/remotion
|
# @json-render/remotion
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "@json-render/shadcn API" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/api/shadcn")
|
||||||
|
|
||||||
# @json-render/shadcn
|
# @json-render/shadcn
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Catalog" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/catalog")
|
||||||
|
|
||||||
# Catalog
|
# Catalog
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Changelog" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/changelog")
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
@@ -8,6 +9,26 @@ Notable changes and updates to json-render.
|
|||||||
|
|
||||||
February 2026
|
February 2026
|
||||||
|
|
||||||
|
### New: External State Store
|
||||||
|
|
||||||
|
The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
|
||||||
|
|
||||||
|
New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
|
||||||
|
|
||||||
|
### Changed: `onStateChange` signature updated (breaking)
|
||||||
|
|
||||||
|
The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before
|
||||||
|
onStateChange?: (path: string, value: unknown) => void
|
||||||
|
|
||||||
|
// After
|
||||||
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
|
||||||
|
```
|
||||||
|
|
||||||
|
The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
|
||||||
|
|
||||||
### New: `@json-render/react-pdf`
|
### New: `@json-render/react-pdf`
|
||||||
|
|
||||||
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Code Export" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/code-export")
|
||||||
|
|
||||||
# Code Export
|
# Code Export
|
||||||
|
|
||||||
@@ -134,6 +135,6 @@ Run the dashboard example and click "Export Project" to see code generation in a
|
|||||||
```bash
|
```bash
|
||||||
cd examples/dashboard
|
cd examples/dashboard
|
||||||
pnpm dev
|
pnpm dev
|
||||||
# Open http://localhost:3001
|
# Open http://dashboard-demo.json-render.localhost:1355
|
||||||
# Generate a widget, then click "Export Project"
|
# Generate a widget, then click "Export Project"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Custom Schema & Renderer" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/custom-schema")
|
||||||
|
|
||||||
# Custom Schema & Renderer
|
# Custom Schema & Renderer
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Data Binding" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/data-binding")
|
||||||
|
|
||||||
# Data Binding
|
# Data Binding
|
||||||
|
|
||||||
@@ -258,6 +259,25 @@ The condition uses the same [visibility](/docs/visibility) expression format.
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react";
|
||||||
|
|
||||||
|
const store = createStateStore({ user: { name: "Alice" } });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — React re-renders automatically:
|
||||||
|
store.set("/user/name", "Bob");
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
- [Visibility](/docs/visibility) — conditionally show or hide elements
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Generation Modes" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/generation-modes")
|
||||||
|
|
||||||
# Generation Modes
|
# Generation Modes
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Installation" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/installation")
|
||||||
|
|
||||||
# Installation
|
# Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Migration Guide" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/migration")
|
||||||
|
|
||||||
# Migration Guide
|
# Migration Guide
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "OpenAPI Integration" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/openapi")
|
||||||
|
|
||||||
# OpenAPI Integration
|
# OpenAPI Integration
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Introduction" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs")
|
||||||
|
|
||||||
# Introduction
|
# Introduction
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Quick Start" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/quick-start")
|
||||||
|
|
||||||
# Quick Start
|
# Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Registry" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/registry")
|
||||||
|
|
||||||
# Registry
|
# Registry
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Schemas" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/schemas")
|
||||||
|
|
||||||
# Schemas
|
# Schemas
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Specs" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/specs")
|
||||||
|
|
||||||
# Specs
|
# Specs
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Streaming" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/streaming")
|
||||||
|
|
||||||
# Streaming
|
# Streaming
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Validation" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/validation")
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const metadata = { title: "Visibility" }
|
import { pageMetadata } from "@/lib/page-metadata"
|
||||||
|
export const metadata = pageMetadata("docs/visibility")
|
||||||
|
|
||||||
# Visibility
|
# Visibility
|
||||||
|
|
||||||
|
|||||||
@@ -154,23 +154,34 @@ button {
|
|||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* MDX table styles — fallback for GFM-generated tables */
|
/* MDX table styles — applies to both GFM pipe tables and raw HTML tables */
|
||||||
.mdx-table th,
|
.mdx-table th,
|
||||||
.mdx-table td {
|
.mdx-table td,
|
||||||
|
article table th,
|
||||||
|
article table td {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdx-table th {
|
.mdx-table th,
|
||||||
|
article table th {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
background-color: var(--muted);
|
background-color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mdx-table td {
|
.mdx-table td,
|
||||||
|
article table td {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
article table {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Shiki dual theme support */
|
/* Shiki dual theme support */
|
||||||
.shiki,
|
.shiki,
|
||||||
.shiki span {
|
.shiki span {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
|
import { GeistPixelSquare } from "geist/font/pixel";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { DocsChat } from "@/components/docs-chat";
|
import { DocsChat } from "@/components/docs-chat";
|
||||||
@@ -61,7 +62,6 @@ export const metadata: Metadata = {
|
|||||||
description:
|
description:
|
||||||
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.",
|
||||||
images: ["/og"],
|
images: ["/og"],
|
||||||
creator: "@verabornnot",
|
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -92,7 +92,9 @@ export default async function RootLayout({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body className={`${geistSans.variable} ${geistMono.variable}`}>
|
<body
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} ${GeistPixelSquare.variable}`}
|
||||||
|
>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
{children}
|
{children}
|
||||||
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
<DocsChat defaultOpen={chatOpen} defaultWidth={chatWidth} />
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import { join } from "node:path";
|
|||||||
export { getPageTitle } from "@/lib/page-titles";
|
export { getPageTitle } from "@/lib/page-titles";
|
||||||
|
|
||||||
// Cache font data in memory after first load
|
// Cache font data in memory after first load
|
||||||
let fontCache: { geistRegular: Buffer } | null = null;
|
let fontCache: { geistRegular: Buffer; geistPixelSquare: Buffer } | null = null;
|
||||||
|
|
||||||
async function loadFonts() {
|
async function loadFonts() {
|
||||||
if (fontCache) return fontCache;
|
if (fontCache) return fontCache;
|
||||||
const geistRegular = await readFile(
|
const [geistRegular, geistPixelSquare] = await Promise.all([
|
||||||
join(process.cwd(), "public/Geist-Regular.ttf"),
|
readFile(join(process.cwd(), "public/Geist-Regular.ttf")),
|
||||||
);
|
readFile(join(process.cwd(), "public/GeistPixel-Square.ttf")),
|
||||||
fontCache = { geistRegular };
|
]);
|
||||||
|
fontCache = { geistRegular, geistPixelSquare };
|
||||||
return fontCache;
|
return fontCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function renderOgImage(title: string) {
|
export async function renderOgImage(title: string) {
|
||||||
const { geistRegular } = await loadFonts();
|
const { geistRegular, geistPixelSquare } = await loadFonts();
|
||||||
|
|
||||||
return new ImageResponse(
|
return new ImageResponse(
|
||||||
<div
|
<div
|
||||||
@@ -53,8 +54,8 @@ export async function renderOgImage(title: string) {
|
|||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontFamily: "Geist",
|
fontFamily: "Geist Pixel Square",
|
||||||
fontWeight: 400,
|
fontWeight: 500,
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -99,6 +100,12 @@ export async function renderOgImage(title: string) {
|
|||||||
style: "normal",
|
style: "normal",
|
||||||
weight: 400,
|
weight: 400,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Geist Pixel Square",
|
||||||
|
data: geistPixelSquare.buffer as ArrayBuffer,
|
||||||
|
style: "normal",
|
||||||
|
weight: 500,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { Playground } from "@/components/playground";
|
import { Playground } from "@/components/playground";
|
||||||
|
import { pageMetadata } from "@/lib/page-metadata";
|
||||||
|
|
||||||
import { PAGE_TITLES } from "@/lib/page-titles";
|
export const metadata = pageMetadata("playground");
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: PAGE_TITLES["playground"],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PlaygroundPage() {
|
export default function PlaygroundPage() {
|
||||||
return <Playground />;
|
return <Playground />;
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function Header() {
|
|||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<span className="font-medium tracking-tight text-lg">
|
<span className="font-medium tracking-tight text-lg font-(family-name:--font-geist-pixel-square)">
|
||||||
json-render
|
json-render
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -16,17 +16,20 @@ import { CopyButton } from "./copy-button";
|
|||||||
import { Toaster } from "./ui/sonner";
|
import { Toaster } from "./ui/sonner";
|
||||||
import { Header } from "./header";
|
import { Header } from "./header";
|
||||||
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
import { Sheet, SheetContent, SheetTitle } from "./ui/sheet";
|
||||||
|
import { JsonEditor } from "@visual-json/react";
|
||||||
|
import type { JsonValue } from "@visual-json/react";
|
||||||
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
import { PlaygroundRenderer } from "@/lib/render/renderer";
|
||||||
import { playgroundCatalog } from "@/lib/render/catalog";
|
import { playgroundCatalog } from "@/lib/render/catalog";
|
||||||
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
|
||||||
|
|
||||||
type Tab = "json" | "nested" | "stream" | "catalog";
|
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
|
||||||
type RenderView = "preview" | "code";
|
type RenderView = "preview" | "code";
|
||||||
type MobileView =
|
type MobileView =
|
||||||
| "json"
|
| "json"
|
||||||
| "nested"
|
| "nested"
|
||||||
| "stream"
|
| "stream"
|
||||||
| "catalog"
|
| "catalog"
|
||||||
|
| "visual"
|
||||||
| "preview"
|
| "preview"
|
||||||
| "generated-code";
|
| "generated-code";
|
||||||
|
|
||||||
@@ -233,6 +236,20 @@ export function Playground() {
|
|||||||
[handleSubmit],
|
[handleSubmit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleVisualChange = useCallback(
|
||||||
|
(value: JsonValue) => {
|
||||||
|
if (!selectedVersionId || isStreaming) return;
|
||||||
|
setVersions((prev) =>
|
||||||
|
prev.map((v) =>
|
||||||
|
v.id === selectedVersionId
|
||||||
|
? { ...v, tree: value as unknown as Spec }
|
||||||
|
: v,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[selectedVersionId, isStreaming],
|
||||||
|
);
|
||||||
|
|
||||||
const jsonCode = currentTree
|
const jsonCode = currentTree
|
||||||
? JSON.stringify(currentTree, null, 2)
|
? JSON.stringify(currentTree, null, 2)
|
||||||
: "// waiting...";
|
: "// waiting...";
|
||||||
@@ -479,31 +496,69 @@ ${jsx}
|
|||||||
? jsonCode
|
? jsonCode
|
||||||
: activeTab === "nested"
|
: activeTab === "nested"
|
||||||
? nestedCode
|
? nestedCode
|
||||||
: "";
|
: activeTab === "visual"
|
||||||
|
? jsonCode
|
||||||
|
: "";
|
||||||
|
|
||||||
const codePane = (
|
const codePane = (
|
||||||
<div className="h-full flex flex-col border-t border-border">
|
<div className="h-full flex flex-col border-t border-border">
|
||||||
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
|
||||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
<button
|
(tab) => (
|
||||||
key={tab}
|
<button
|
||||||
onClick={() => setActiveTab(tab)}
|
key={tab}
|
||||||
className={`text-xs font-mono transition-colors ${
|
onClick={() => setActiveTab(tab)}
|
||||||
activeTab === tab
|
className={`text-xs font-mono transition-colors ${
|
||||||
? "text-foreground"
|
activeTab === tab
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-foreground"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
{tab}
|
>
|
||||||
</button>
|
{tab}
|
||||||
))}
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{activeTab !== "catalog" && (
|
{activeTab !== "catalog" && activeTab !== "visual" && (
|
||||||
<CopyButton text={copyText} className="text-muted-foreground" />
|
<CopyButton text={copyText} className="text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
{activeTab === "catalog" ? (
|
{activeTab === "visual" ? (
|
||||||
|
currentTree ? (
|
||||||
|
<JsonEditor
|
||||||
|
value={currentTree as unknown as JsonValue}
|
||||||
|
onChange={handleVisualChange}
|
||||||
|
readOnly={isStreaming}
|
||||||
|
sidebarOpen={false}
|
||||||
|
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 className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||||
|
{"// generate a spec to edit visually"}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : activeTab === "catalog" ? (
|
||||||
<div className="h-full flex flex-col text-sm">
|
<div className="h-full flex flex-col text-sm">
|
||||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||||
{(
|
{(
|
||||||
@@ -735,19 +790,21 @@ ${jsx}
|
|||||||
: 0}
|
: 0}
|
||||||
</button>
|
</button>
|
||||||
{/* Code tabs */}
|
{/* Code tabs */}
|
||||||
{(["json", "nested", "stream", "catalog"] as const).map((tab) => (
|
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
|
||||||
<button
|
(tab) => (
|
||||||
key={tab}
|
<button
|
||||||
onClick={() => setMobileView(tab)}
|
key={tab}
|
||||||
className={`text-xs font-mono transition-colors shrink-0 ${
|
onClick={() => setMobileView(tab)}
|
||||||
mobileView === tab
|
className={`text-xs font-mono transition-colors shrink-0 ${
|
||||||
? "text-foreground"
|
mobileView === tab
|
||||||
: "text-muted-foreground hover:text-foreground"
|
? "text-foreground"
|
||||||
}`}
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
{tab}
|
>
|
||||||
</button>
|
{tab}
|
||||||
))}
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{/* Preview / code toggle */}
|
{/* Preview / code toggle */}
|
||||||
{[
|
{[
|
||||||
@@ -770,7 +827,41 @@ ${jsx}
|
|||||||
|
|
||||||
{/* Main content area */}
|
{/* Main content area */}
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
{mobileView === "catalog" ? (
|
{mobileView === "visual" ? (
|
||||||
|
currentTree ? (
|
||||||
|
<JsonEditor
|
||||||
|
value={currentTree as unknown as JsonValue}
|
||||||
|
onChange={handleVisualChange}
|
||||||
|
readOnly={isStreaming}
|
||||||
|
sidebarOpen={false}
|
||||||
|
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 className="text-muted-foreground/50 p-3 text-sm font-mono">
|
||||||
|
{"// generate a spec to edit visually"}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : mobileView === "catalog" ? (
|
||||||
<div className="h-full flex flex-col text-sm">
|
<div className="h-full flex flex-col text-sm">
|
||||||
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
<div className="flex items-center gap-3 px-3 h-9 border-b border-border">
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export const docsNavigation: NavSection[] = [
|
|||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-native",
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "React PDF",
|
||||||
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/react-pdf",
|
||||||
|
external: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Remotion",
|
title: "Remotion",
|
||||||
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
href: "https://github.com/vercel-labs/json-render/tree/main/examples/remotion",
|
||||||
@@ -86,6 +91,7 @@ export const docsNavigation: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ title: "@json-render/core", href: "/docs/api/core" },
|
{ title: "@json-render/core", href: "/docs/api/core" },
|
||||||
{ title: "@json-render/react", href: "/docs/api/react" },
|
{ title: "@json-render/react", href: "/docs/api/react" },
|
||||||
|
{ title: "@json-render/react-pdf", href: "/docs/api/react-pdf" },
|
||||||
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
{ title: "@json-render/shadcn", href: "/docs/api/shadcn" },
|
||||||
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
{ title: "@json-render/react-native", href: "/docs/api/react-native" },
|
||||||
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
{ title: "@json-render/remotion", href: "/docs/api/remotion" },
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { PAGE_TITLES } from "./page-titles";
|
||||||
|
|
||||||
|
const DESCRIPTION =
|
||||||
|
"The Generative UI framework. Generate dashboards, widgets, and apps from prompts — safely constrained to components you define.";
|
||||||
|
|
||||||
|
export function pageMetadata(slug: string): Metadata {
|
||||||
|
const title = PAGE_TITLES[slug];
|
||||||
|
if (!title) return {};
|
||||||
|
|
||||||
|
const displayTitle = title.replace(/\n/g, " ");
|
||||||
|
const fullTitle = `${displayTitle} | json-render`;
|
||||||
|
const ogImageUrl = slug ? `/og/${slug}` : "/og";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: displayTitle,
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "en_US",
|
||||||
|
siteName: "json-render",
|
||||||
|
title: fullTitle,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
url: ogImageUrl,
|
||||||
|
width: 1200,
|
||||||
|
height: 630,
|
||||||
|
alt: `${displayTitle} - json-render`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary_large_image",
|
||||||
|
title: fullTitle,
|
||||||
|
description: DESCRIPTION,
|
||||||
|
images: [ogImageUrl],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,9 +38,11 @@ export const PAGE_TITLES: Record<string, string> = {
|
|||||||
// API references
|
// API references
|
||||||
"docs/api/core": "@json-render/core API",
|
"docs/api/core": "@json-render/core API",
|
||||||
"docs/api/react": "@json-render/react API",
|
"docs/api/react": "@json-render/react API",
|
||||||
|
"docs/api/react-pdf": "@json-render/react-pdf API",
|
||||||
"docs/api/react-native": "@json-render/react-native API",
|
"docs/api/react-native": "@json-render/react-native API",
|
||||||
"docs/api/codegen": "@json-render/codegen API",
|
"docs/api/codegen": "@json-render/codegen API",
|
||||||
"docs/api/remotion": "@json-render/remotion API",
|
"docs/api/remotion": "@json-render/remotion API",
|
||||||
|
"docs/api/shadcn": "@json-render/shadcn API",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "portless json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -28,11 +28,13 @@
|
|||||||
"@upstash/redis": "^1.36.1",
|
"@upstash/redis": "^1.36.1",
|
||||||
"@vercel/analytics": "^1.6.1",
|
"@vercel/analytics": "^1.6.1",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
|
"@visual-json/react": "0.1.1",
|
||||||
"ai": "^6.0.33",
|
"ai": "^6.0.33",
|
||||||
"bash-tool": "1.3.14",
|
"bash-tool": "1.3.14",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"geist": "1.7.0",
|
||||||
"just-bash": "2.9.6",
|
"just-bash": "2.9.6",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"next": "16.1.1",
|
"next": "16.1.1",
|
||||||
@@ -51,8 +53,8 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/mdx": "^2.0.13",
|
"@types/mdx": "^2.0.13",
|
||||||
"@types/node": "^22.15.3",
|
"@types/node": "^22.15.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/nextjs.json",
|
"extends": "@internal/typescript-config/nextjs.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "portless chat-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"zod": "4.3.5"
|
"zod": "4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
@@ -196,24 +196,28 @@ export function Widget({
|
|||||||
onDeleted?.();
|
onDeleted?.();
|
||||||
}, [widgetId, onDeleted]);
|
}, [widgetId, onDeleted]);
|
||||||
|
|
||||||
const handleStateChange = useCallback((path: string, value: unknown) => {
|
const handleStateChange = useCallback(
|
||||||
setState((prev) => {
|
(changes: Array<{ path: string; value: unknown }>) => {
|
||||||
const next = { ...prev };
|
setState((prev) => {
|
||||||
// Convert path like "customerForm/name" to nested object
|
const next = { ...prev };
|
||||||
const parts = path.split("/");
|
for (const { path, value } of changes) {
|
||||||
let current: Record<string, unknown> = next;
|
const parts = path.split("/");
|
||||||
for (let i = 0; i < parts.length - 1; i++) {
|
let current: Record<string, unknown> = next;
|
||||||
const part = parts[i]!;
|
for (let i = 0; i < parts.length - 1; i++) {
|
||||||
if (!(part in current) || typeof current[part] !== "object") {
|
const part = parts[i]!;
|
||||||
current[part] = {};
|
if (!(part in current) || typeof current[part] !== "object") {
|
||||||
|
current[part] = {};
|
||||||
|
}
|
||||||
|
current = current[part] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const lastPart = parts[parts.length - 1]!;
|
||||||
|
current[lastPart] = value;
|
||||||
}
|
}
|
||||||
current = current[part] as Record<string, unknown>;
|
return next;
|
||||||
}
|
});
|
||||||
const lastPart = parts[parts.length - 1]!;
|
},
|
||||||
current[lastPart] = value;
|
[],
|
||||||
return next;
|
);
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use spec from stream, or initial spec for saved widgets
|
// Use spec from stream, or initial spec for saved widgets
|
||||||
const currentSpec = spec || initialSpec;
|
const currentSpec = spec || initialSpec;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface DashboardRendererProps {
|
|||||||
spec: Spec | null;
|
spec: Spec | null;
|
||||||
state?: Record<string, unknown>;
|
state?: Record<string, unknown>;
|
||||||
setState?: SetState;
|
setState?: SetState;
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "portless dashboard-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { nextJsConfig } from "@repo/eslint-config/next-js";
|
import { nextJsConfig } from "@internal/eslint-config/next-js";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack --port 3003",
|
"dev": "portless no-ai-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint --max-warnings 0",
|
"lint": "eslint --max-warnings 0",
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
"zod": "4.3.5"
|
"zod": "4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack --port 3005",
|
"dev": "portless react-pdf-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
"zod": "4.3.5"
|
"zod": "4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack --port 3002",
|
"dev": "portless remotion-demo.json-render next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"check-types": "tsc --noEmit"
|
"check-types": "tsc --noEmit"
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --port 3001",
|
"dev": "portless stripe-api-demo.json-render next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start --port 3001"
|
"start": "next start --port 3001"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
import { config as reactConfig } from "@internal/eslint-config/react-internal";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@stripe/ui-extension-tools": "^0.0.1",
|
"@stripe/ui-extension-tools": "^0.0.1",
|
||||||
"eslint": "^9.39.0"
|
"eslint": "^9.39.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
export const API_GENERATE_URL =
|
||||||
|
"http://stripe-api-demo.json-render.localhost:1355/api/generate";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
|
|||||||
/** Function to update data */
|
/** Function to update data */
|
||||||
setData?: SetState;
|
setData?: SetState;
|
||||||
/** Callback when data changes */
|
/** Callback when data changes */
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
/** Whether the spec is currently loading/streaming */
|
/** Whether the spec is currently loading/streaming */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { config as reactConfig } from "@repo/eslint-config/react-internal";
|
import { config as reactConfig } from "@internal/eslint-config/react-internal";
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config[]} */
|
/** @type {import("eslint").Linter.Config[]} */
|
||||||
export default [
|
export default [
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/eslint-config": "workspace:*",
|
"@internal/eslint-config": "workspace:*",
|
||||||
"@stripe/ui-extension-tools": "^0.0.1",
|
"@stripe/ui-extension-tools": "^0.0.1",
|
||||||
"eslint": "^9.39.0"
|
"eslint": "^9.39.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export const API_GENERATE_URL = "http://localhost:3001/api/generate";
|
export const API_GENERATE_URL =
|
||||||
|
"http://stripe-api-demo.json-render.localhost:1355/api/generate";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export interface StripeRendererProps {
|
|||||||
/** Function to update data */
|
/** Function to update data */
|
||||||
setData?: SetState;
|
setData?: SetState;
|
||||||
/** Callback when data changes */
|
/** Callback when data changes */
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
/** Whether the spec is currently loading/streaming */
|
/** Whether the spec is currently loading/streaming */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"test:e2e": "pnpm --filter e2e-tests test",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"changeset": "changeset",
|
"changeset": "changeset",
|
||||||
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
"@json-render/core": "workspace:*"
|
"@json-render/core": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/base.json",
|
"extends": "@internal/typescript-config/base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
|
|||||||
@@ -221,6 +221,63 @@ Schema options:
|
|||||||
|
|
||||||
The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
|
The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
|
||||||
|
|
||||||
|
### State Store
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `createStateStore(initialState?)` | Create a framework-agnostic in-memory `StateStore` |
|
||||||
|
| `StateStore` | Interface for plugging in external state management (Redux, Zustand, XState, etc.) |
|
||||||
|
| `StateModel` | State model type (`Record<string, unknown>`) |
|
||||||
|
|
||||||
|
The `StateStore` interface allows renderers to use external state management instead of the built-in internal store:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/core";
|
||||||
|
|
||||||
|
// Simple in-memory store
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
store.get("/count"); // 0
|
||||||
|
store.set("/count", 1); // updates and notifies subscribers
|
||||||
|
store.getSnapshot(); // { count: 1 }
|
||||||
|
|
||||||
|
// Subscribe to changes (compatible with React's useSyncExternalStore)
|
||||||
|
const unsubscribe = store.subscribe(() => {
|
||||||
|
console.log("state changed:", store.getSnapshot());
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Pass the store to `StateProvider` in any renderer package (`@json-render/react`, `@json-render/react-native`, `@json-render/react-pdf`) for controlled mode.
|
||||||
|
|
||||||
|
### Store Utilities (for adapter authors)
|
||||||
|
|
||||||
|
Available via `@json-render/core/store-utils`:
|
||||||
|
|
||||||
|
| Export | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `createStoreAdapter(config)` | Build a full `StateStore` from a minimal `{ getSnapshot, setSnapshot, subscribe }` config |
|
||||||
|
| `immutableSetByPath(root, path, value)` | Immutably set a value at a JSON Pointer path with structural sharing |
|
||||||
|
| `flattenToPointers(obj)` | Flatten a nested object into JSON Pointer keyed entries |
|
||||||
|
| `StoreAdapterConfig` | Config type for `createStoreAdapter` |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStoreAdapter, immutableSetByPath, flattenToPointers } from "@json-render/core/store-utils";
|
||||||
|
```
|
||||||
|
|
||||||
|
`createStoreAdapter` handles `get`, `set` (with no-op detection), batched `update`, `getSnapshot`, `getServerSnapshot`, and `subscribe` -- adapter authors only need to supply the snapshot source, write API, and subscribe mechanism:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||||
|
|
||||||
|
const store = createStoreAdapter({
|
||||||
|
getSnapshot: () => myLib.getState(),
|
||||||
|
setSnapshot: (next) => myLib.setState(next),
|
||||||
|
subscribe: (listener) => myLib.subscribe(listener),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`) are all built on top of `createStoreAdapter`.
|
||||||
|
|
||||||
### Types
|
### Types
|
||||||
|
|
||||||
| Export | Purpose |
|
| Export | Purpose |
|
||||||
|
|||||||
@@ -34,6 +34,11 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"import": "./dist/index.mjs",
|
"import": "./dist/index.mjs",
|
||||||
"require": "./dist/index.js"
|
"require": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./store-utils": {
|
||||||
|
"types": "./dist/store-utils.d.ts",
|
||||||
|
"import": "./dist/store-utils.mjs",
|
||||||
|
"require": "./dist/store-utils.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
@@ -48,7 +53,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
|
|||||||
Vendored
+9
@@ -0,0 +1,9 @@
|
|||||||
|
// Minimal process.env typing for dev-only warnings.
|
||||||
|
// Uses a namespaced interface so it merges cleanly with @types/node if present.
|
||||||
|
declare namespace NodeJS {
|
||||||
|
interface ProcessEnv {
|
||||||
|
readonly NODE_ENV?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare const process: { readonly env: NodeJS.ProcessEnv };
|
||||||
@@ -15,6 +15,7 @@ export type {
|
|||||||
AndCondition,
|
AndCondition,
|
||||||
OrCondition,
|
OrCondition,
|
||||||
StateModel,
|
StateModel,
|
||||||
|
StateStore,
|
||||||
ComponentSchema,
|
ComponentSchema,
|
||||||
ValidationMode,
|
ValidationMode,
|
||||||
PatchOp,
|
PatchOp,
|
||||||
@@ -57,6 +58,10 @@ export {
|
|||||||
SPEC_DATA_PART_TYPE,
|
SPEC_DATA_PART_TYPE,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
|
// State Store
|
||||||
|
export type { StoreAdapterConfig } from "./state-store";
|
||||||
|
export { createStateStore } from "./state-store";
|
||||||
|
|
||||||
// Visibility
|
// Visibility
|
||||||
export type { VisibilityContext } from "./visibility";
|
export type { VisibilityContext } from "./visibility";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { createStateStore, flattenToPointers } from "./state-store";
|
||||||
|
|
||||||
|
describe("createStateStore", () => {
|
||||||
|
it("creates a store with initial state", () => {
|
||||||
|
const store = createStateStore({ name: "test" });
|
||||||
|
expect(store.getSnapshot()).toEqual({ name: "test" });
|
||||||
|
expect(store.get("/name")).toBe("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set notifies subscribers", () => {
|
||||||
|
const store = createStateStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.get("/x")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set skips notification when value is unchanged", () => {
|
||||||
|
const store = createStateStore({ x: 1 });
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot()).toEqual({ x: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update notifies subscribers once", () => {
|
||||||
|
const store = createStateStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.get("/a")).toBe(1);
|
||||||
|
expect(store.get("/b")).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update skips notification when no values changed", () => {
|
||||||
|
const store = createStateStore({ a: 1, b: 2 });
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribe stops notifications", () => {
|
||||||
|
const store = createStateStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSnapshot returns a new reference after mutation", () => {
|
||||||
|
const store = createStateStore({ x: 1 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/x", 2);
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap1).not.toBe(snap2);
|
||||||
|
expect(snap2.x).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSnapshot returns same reference when set is a no-op", () => {
|
||||||
|
const store = createStateStore({ x: 1 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap1).toBe(snap2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set on nested path does not mutate previous snapshot", () => {
|
||||||
|
const store = createStateStore({ user: { name: "Alice", age: 30 } });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/user/name", "Bob");
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||||
|
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
expect(snap1.user).not.toBe(snap2.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update on nested paths does not mutate previous snapshot", () => {
|
||||||
|
const store = createStateStore({
|
||||||
|
user: { name: "Alice" },
|
||||||
|
meta: { version: 1 },
|
||||||
|
});
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.update({ "/user/name": "Bob", "/meta/version": 2 });
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect((snap1.user as Record<string, unknown>).name).toBe("Alice");
|
||||||
|
expect((snap1.meta as Record<string, unknown>).version).toBe(1);
|
||||||
|
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
expect((snap2.meta as Record<string, unknown>).version).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set preserves structural sharing for untouched branches", () => {
|
||||||
|
const store = createStateStore({
|
||||||
|
a: { x: 1 },
|
||||||
|
b: { y: 2 },
|
||||||
|
});
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/a/x", 99);
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap2.b).toBe(snap1.b);
|
||||||
|
expect(snap2.a).not.toBe(snap1.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getServerSnapshot returns the same state as getSnapshot", () => {
|
||||||
|
const store = createStateStore({ x: 1 });
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("flattenToPointers", () => {
|
||||||
|
it("flattens top-level keys", () => {
|
||||||
|
expect(flattenToPointers({ a: 1, b: "hello" })).toEqual({
|
||||||
|
"/a": 1,
|
||||||
|
"/b": "hello",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flattens nested plain objects", () => {
|
||||||
|
expect(flattenToPointers({ user: { name: "Alice", age: 30 } })).toEqual({
|
||||||
|
"/user/name": "Alice",
|
||||||
|
"/user/age": 30,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves arrays as leaf values", () => {
|
||||||
|
expect(flattenToPointers({ items: [1, 2, 3] })).toEqual({
|
||||||
|
"/items": [1, 2, 3],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves null as a leaf value", () => {
|
||||||
|
expect(flattenToPointers({ x: null })).toEqual({ "/x": null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles deeply nested objects", () => {
|
||||||
|
expect(flattenToPointers({ a: { b: { c: 42 } } })).toEqual({
|
||||||
|
"/a/b/c": 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty object for empty input", () => {
|
||||||
|
expect(flattenToPointers({})).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles mixed nesting", () => {
|
||||||
|
expect(
|
||||||
|
flattenToPointers({
|
||||||
|
count: 1,
|
||||||
|
user: { name: "Alice" },
|
||||||
|
tags: ["a", "b"],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
"/count": 1,
|
||||||
|
"/user/name": "Alice",
|
||||||
|
"/tags": ["a", "b"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops recursion on circular references via seen set", () => {
|
||||||
|
const obj: Record<string, unknown> = { name: "root" };
|
||||||
|
obj.self = obj;
|
||||||
|
|
||||||
|
const result = flattenToPointers(obj);
|
||||||
|
|
||||||
|
expect(result["/name"]).toBe("root");
|
||||||
|
expect(result["/self/name"]).toBe("root");
|
||||||
|
expect(result["/self/self"]).toBe(obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps recursion at depth limit", () => {
|
||||||
|
let current: Record<string, unknown> = { leaf: true };
|
||||||
|
for (let i = 0; i < 25; i++) {
|
||||||
|
current = { nested: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = flattenToPointers(current);
|
||||||
|
|
||||||
|
const keys = Object.keys(result);
|
||||||
|
expect(keys.length).toBe(1);
|
||||||
|
const key = keys[0]!;
|
||||||
|
expect(key.split("/").length).toBeLessThanOrEqual(22);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import {
|
||||||
|
getByPath,
|
||||||
|
parseJsonPointer,
|
||||||
|
type StateModel,
|
||||||
|
type StateStore,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutably set a value at a JSON Pointer path using structural sharing.
|
||||||
|
* Only objects along the path are shallow-cloned; untouched branches keep
|
||||||
|
* their original references.
|
||||||
|
*/
|
||||||
|
export function immutableSetByPath(
|
||||||
|
root: StateModel,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): StateModel {
|
||||||
|
const segments = parseJsonPointer(path);
|
||||||
|
if (segments.length === 0) return root;
|
||||||
|
|
||||||
|
const result = { ...root };
|
||||||
|
let current: Record<string, unknown> = result;
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const seg = segments[i]!;
|
||||||
|
const child = current[seg];
|
||||||
|
if (Array.isArray(child)) {
|
||||||
|
current[seg] = [...child];
|
||||||
|
} else if (child !== null && typeof child === "object") {
|
||||||
|
current[seg] = { ...(child as Record<string, unknown>) };
|
||||||
|
} else {
|
||||||
|
const nextSeg = segments[i + 1];
|
||||||
|
current[seg] = nextSeg !== undefined && /^\d+$/.test(nextSeg) ? [] : {};
|
||||||
|
}
|
||||||
|
current = current[seg] as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSeg = segments[segments.length - 1]!;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (lastSeg === "-") {
|
||||||
|
(current as unknown[]).push(value);
|
||||||
|
} else {
|
||||||
|
(current as unknown[])[parseInt(lastSeg, 10)] = value;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current[lastSeg] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a simple in-memory {@link StateStore}.
|
||||||
|
*
|
||||||
|
* This is the default store used by `StateProvider` when no external store is
|
||||||
|
* provided. It mirrors the previous `useState`-based behaviour but is
|
||||||
|
* framework-agnostic so it can also be used in tests or non-React contexts.
|
||||||
|
*/
|
||||||
|
export function createStateStore(initialState: StateModel = {}): StateStore {
|
||||||
|
let state: StateModel = { ...initialState };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
for (const listener of listeners) {
|
||||||
|
listener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(path: string): unknown {
|
||||||
|
return getByPath(state, path);
|
||||||
|
},
|
||||||
|
|
||||||
|
set(path: string, value: unknown): void {
|
||||||
|
if (getByPath(state, path) === value) return;
|
||||||
|
state = immutableSetByPath(state, path, value);
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
update(updates: Record<string, unknown>): void {
|
||||||
|
let changed = false;
|
||||||
|
let next = state;
|
||||||
|
for (const [path, value] of Object.entries(updates)) {
|
||||||
|
if (getByPath(next, path) !== value) {
|
||||||
|
next = immutableSetByPath(next, path, value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
state = next;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
|
||||||
|
getSnapshot(): StateModel {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
|
||||||
|
getServerSnapshot(): StateModel {
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for {@link createStoreAdapter}. Adapter authors supply these
|
||||||
|
* three callbacks; everything else (get, set, update, no-op detection,
|
||||||
|
* getServerSnapshot) is handled by the returned {@link StateStore}.
|
||||||
|
*/
|
||||||
|
export interface StoreAdapterConfig {
|
||||||
|
/** Return the current state snapshot from the underlying store. */
|
||||||
|
getSnapshot: () => StateModel;
|
||||||
|
/** Write a new state snapshot to the underlying store. */
|
||||||
|
setSnapshot: (next: StateModel) => void;
|
||||||
|
/** Subscribe to changes in the underlying store. Return an unsubscribe fn. */
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a full {@link StateStore} from a minimal adapter config.
|
||||||
|
*
|
||||||
|
* Handles `get`, `set` (with no-op detection), `update` (batched, with no-op
|
||||||
|
* detection), `getSnapshot`, `getServerSnapshot`, and `subscribe` -- so each
|
||||||
|
* adapter only needs to wire its snapshot source, write API, and subscribe
|
||||||
|
* mechanism.
|
||||||
|
*/
|
||||||
|
export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
|
||||||
|
return {
|
||||||
|
get(path: string): unknown {
|
||||||
|
return getByPath(config.getSnapshot(), path);
|
||||||
|
},
|
||||||
|
|
||||||
|
set(path: string, value: unknown): void {
|
||||||
|
const current = config.getSnapshot();
|
||||||
|
if (getByPath(current, path) === value) return;
|
||||||
|
config.setSnapshot(immutableSetByPath(current, path, value));
|
||||||
|
},
|
||||||
|
|
||||||
|
update(updates: Record<string, unknown>): void {
|
||||||
|
let next = config.getSnapshot();
|
||||||
|
let changed = false;
|
||||||
|
for (const [path, value] of Object.entries(updates)) {
|
||||||
|
if (getByPath(next, path) !== value) {
|
||||||
|
next = immutableSetByPath(next, path, value);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!changed) return;
|
||||||
|
config.setSnapshot(next);
|
||||||
|
},
|
||||||
|
|
||||||
|
getSnapshot: config.getSnapshot,
|
||||||
|
|
||||||
|
getServerSnapshot: config.getSnapshot,
|
||||||
|
|
||||||
|
subscribe: config.subscribe,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FLATTEN_DEPTH = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively flatten a plain object into a `Record<string, unknown>` keyed by
|
||||||
|
* JSON Pointer paths. Only leaf values (non-plain-object) appear in the output.
|
||||||
|
*
|
||||||
|
* Includes circular reference protection and a depth cap to prevent stack
|
||||||
|
* overflow on pathological inputs.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* flattenToPointers({ user: { name: "Alice" }, count: 1 })
|
||||||
|
* // => { "/user/name": "Alice", "/count": 1 }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function flattenToPointers(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
prefix = "",
|
||||||
|
_depth = 0,
|
||||||
|
_seen?: Set<object>,
|
||||||
|
_warned?: { current: boolean },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const seen = _seen ?? new Set<object>();
|
||||||
|
const warned = _warned ?? { current: false };
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
const pointer = `${prefix}/${key}`;
|
||||||
|
if (
|
||||||
|
_depth < MAX_FLATTEN_DEPTH &&
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.getPrototypeOf(value) === Object.prototype &&
|
||||||
|
!seen.has(value)
|
||||||
|
) {
|
||||||
|
seen.add(value);
|
||||||
|
Object.assign(
|
||||||
|
result,
|
||||||
|
flattenToPointers(
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
pointer,
|
||||||
|
_depth + 1,
|
||||||
|
seen,
|
||||||
|
warned,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
process.env.NODE_ENV !== "production" &&
|
||||||
|
!warned.current &&
|
||||||
|
_depth >= MAX_FLATTEN_DEPTH &&
|
||||||
|
value !== null &&
|
||||||
|
typeof value === "object" &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
Object.getPrototypeOf(value) === Object.prototype &&
|
||||||
|
!seen.has(value as object)
|
||||||
|
) {
|
||||||
|
warned.current = true;
|
||||||
|
console.warn(
|
||||||
|
`flattenToPointers: depth limit (${MAX_FLATTEN_DEPTH}) reached. Nested state beyond this depth will be treated as a leaf value.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result[pointer] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
immutableSetByPath,
|
||||||
|
flattenToPointers,
|
||||||
|
createStoreAdapter,
|
||||||
|
} from "./state-store";
|
||||||
|
export type { StoreAdapterConfig } from "./state-store";
|
||||||
@@ -178,6 +178,36 @@ export interface Spec {
|
|||||||
*/
|
*/
|
||||||
export type StateModel = Record<string, unknown>;
|
export type StateModel = Record<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An abstract store that owns state and notifies subscribers on change.
|
||||||
|
*
|
||||||
|
* Consumers can supply their own implementation (backed by Redux, Zustand,
|
||||||
|
* XState, etc.) or use the built-in {@link createStateStore} for a simple
|
||||||
|
* in-memory store.
|
||||||
|
*/
|
||||||
|
export interface StateStore {
|
||||||
|
/** Read a value by JSON Pointer path. */
|
||||||
|
get: (path: string) => unknown;
|
||||||
|
/**
|
||||||
|
* Write a value by JSON Pointer path and notify subscribers.
|
||||||
|
* Equality is checked by reference (`===`), not deep comparison.
|
||||||
|
* Callers must pass a new object/array reference for changes to be detected.
|
||||||
|
*/
|
||||||
|
set: (path: string, value: unknown) => void;
|
||||||
|
/**
|
||||||
|
* Write multiple values at once and notify subscribers (single notification).
|
||||||
|
* Each value is compared by reference (`===`); only paths whose value
|
||||||
|
* actually changed are applied.
|
||||||
|
*/
|
||||||
|
update: (updates: Record<string, unknown>) => void;
|
||||||
|
/** Return the full state object (used by `useSyncExternalStore`). */
|
||||||
|
getSnapshot: () => StateModel;
|
||||||
|
/** Optional server snapshot for SSR (passed to `useSyncExternalStore`). Falls back to `getSnapshot` when omitted. */
|
||||||
|
getServerSnapshot?: () => StateModel;
|
||||||
|
/** Register a listener that is called on every state change. Returns an unsubscribe function. */
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Component schema definition using Zod
|
* Component schema definition using Zod
|
||||||
*/
|
*/
|
||||||
@@ -236,7 +266,7 @@ function unescapeJsonPointer(token: string): string {
|
|||||||
/**
|
/**
|
||||||
* Parse a JSON Pointer path into unescaped segments.
|
* Parse a JSON Pointer path into unescaped segments.
|
||||||
*/
|
*/
|
||||||
function parseJsonPointer(path: string): string[] {
|
export function parseJsonPointer(path: string): string[] {
|
||||||
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||||
return raw.map(unescapeJsonPointer);
|
return raw.map(unescapeJsonPointer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/react-library.json",
|
"extends": "@internal/typescript-config/react-library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { defineConfig } from "tsup";
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: ["src/index.ts"],
|
entry: ["src/index.ts", "src/store-utils.ts"],
|
||||||
format: ["cjs", "esm"],
|
format: ["cjs", "esm"],
|
||||||
dts: true,
|
dts: true,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@repo/eslint-config",
|
"name": "@internal/eslint-config",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# @json-render/jotai
|
||||||
|
|
||||||
|
Jotai adapter for json-render's `StateStore` interface. Wire a Jotai atom as the state backend for json-render.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install @json-render/jotai @json-render/core @json-render/react jotai
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
// 1. Create an atom that holds the json-render state
|
||||||
|
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
|
||||||
|
// 2. Create the json-render StateStore adapter
|
||||||
|
const store = jotaiStateStore({ atom: uiAtom });
|
||||||
|
|
||||||
|
// 3. Use it
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* json-render reads/writes go through Jotai */}
|
||||||
|
</StateProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
### With a shared Jotai store
|
||||||
|
|
||||||
|
If your app already uses a Jotai `<Provider>` with a custom store, pass it so both json-render and your components share the same state:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { atom, createStore } from "jotai";
|
||||||
|
import { Provider as JotaiProvider } from "jotai/react";
|
||||||
|
import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
import { StateProvider } from "@json-render/react";
|
||||||
|
|
||||||
|
const jStore = createStore();
|
||||||
|
const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
|
||||||
|
const store = jotaiStateStore({ atom: uiAtom, store: jStore });
|
||||||
|
|
||||||
|
<JotaiProvider store={jStore}>
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{/* Both json-render and useAtom() see the same state */}
|
||||||
|
</StateProvider>
|
||||||
|
</JotaiProvider>
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `jotaiStateStore(options)`
|
||||||
|
|
||||||
|
Creates a `StateStore` backed by a Jotai atom.
|
||||||
|
|
||||||
|
#### Options
|
||||||
|
|
||||||
|
| Option | Type | Required | Description |
|
||||||
|
|--------|------|----------|-------------|
|
||||||
|
| `atom` | `WritableAtom<StateModel, [StateModel], void>` | Yes | A writable atom holding the state model |
|
||||||
|
| `store` | Jotai `Store` | No | The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with `<Provider>`. |
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "@json-render/jotai",
|
||||||
|
"version": "0.8.0",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"description": "Jotai adapter for json-render StateStore",
|
||||||
|
"keywords": [
|
||||||
|
"json-render",
|
||||||
|
"jotai",
|
||||||
|
"state-management",
|
||||||
|
"adapter"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/vercel-labs/json-render.git",
|
||||||
|
"directory": "packages/jotai"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/vercel-labs/json-render#readme",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/vercel-labs/json-render/issues"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"module": "./dist/index.mjs",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.mjs",
|
||||||
|
"require": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"dev": "tsup --watch",
|
||||||
|
"check-types": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@json-render/core": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"jotai": ">=2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@internal/typescript-config": "workspace:*",
|
||||||
|
"jotai": "^2.18.0",
|
||||||
|
"tsup": "^8.0.2",
|
||||||
|
"typescript": "^5.4.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { atom } from "jotai";
|
||||||
|
import { createStore } from "jotai/vanilla";
|
||||||
|
import { jotaiStateStore } from "./index";
|
||||||
|
|
||||||
|
function createTestStore(initial: Record<string, unknown> = {}) {
|
||||||
|
const stateAtom = atom<Record<string, unknown>>(initial);
|
||||||
|
const jStore = createStore();
|
||||||
|
const store = jotaiStateStore({ atom: stateAtom, store: jStore });
|
||||||
|
return { stateAtom, jStore, store };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("jotaiStateStore", () => {
|
||||||
|
it("get/set round-trip", () => {
|
||||||
|
const { store } = createTestStore({ count: 0 });
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(0);
|
||||||
|
|
||||||
|
store.set("/count", 42);
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(42);
|
||||||
|
expect(store.getSnapshot().count).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update round-trip with multiple values", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": "hello" });
|
||||||
|
|
||||||
|
expect(store.get("/a")).toBe(1);
|
||||||
|
expect(store.get("/b")).toBe("hello");
|
||||||
|
expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on set", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribe fires on update", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unsubscribe stops notifications", () => {
|
||||||
|
const { store } = createTestStore({});
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsub = store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unsub();
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSnapshot immutability -- previous snapshot is not mutated", () => {
|
||||||
|
const { store } = createTestStore({ user: { name: "Alice", age: 30 } });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/user/name", "Bob");
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap1.user).toEqual({ name: "Alice", age: 30 });
|
||||||
|
expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
expect(snap1.user).not.toBe(snap2.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("structural sharing -- untouched branches keep references", () => {
|
||||||
|
const { store } = createTestStore({
|
||||||
|
a: { x: 1 },
|
||||||
|
b: { y: 2 },
|
||||||
|
});
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
|
||||||
|
store.set("/a/x", 99);
|
||||||
|
const snap2 = store.getSnapshot();
|
||||||
|
|
||||||
|
expect(snap2.b).toBe(snap1.b);
|
||||||
|
expect(snap2.a).not.toBe(snap1.a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getServerSnapshot returns same as getSnapshot", () => {
|
||||||
|
const { store } = createTestStore({ x: 1 });
|
||||||
|
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
|
||||||
|
store.set("/x", 2);
|
||||||
|
expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("set skips update when value is unchanged", () => {
|
||||||
|
const { store } = createTestStore({ x: 1 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.set("/x", 1);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot()).toBe(snap1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update skips update when no values changed", () => {
|
||||||
|
const { store } = createTestStore({ a: 1, b: 2 });
|
||||||
|
const snap1 = store.getSnapshot();
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.subscribe(listener);
|
||||||
|
|
||||||
|
store.update({ "/a": 1, "/b": 2 });
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot()).toBe(snap1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads from the shared Jotai store", () => {
|
||||||
|
const stateAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
const jStore = createStore();
|
||||||
|
const store = jotaiStateStore({ atom: stateAtom, store: jStore });
|
||||||
|
|
||||||
|
jStore.set(stateAtom, { count: 99 });
|
||||||
|
|
||||||
|
expect(store.get("/count")).toBe(99);
|
||||||
|
expect(store.getSnapshot().count).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an internal store when none is provided", () => {
|
||||||
|
const stateAtom = atom<Record<string, unknown>>({ value: "hello" });
|
||||||
|
const store = jotaiStateStore({ atom: stateAtom });
|
||||||
|
|
||||||
|
expect(store.get("/value")).toBe("hello");
|
||||||
|
|
||||||
|
store.set("/value", "world");
|
||||||
|
expect(store.get("/value")).toBe("world");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { StateModel, StateStore } from "@json-render/core";
|
||||||
|
import { createStoreAdapter } from "@json-render/core/store-utils";
|
||||||
|
import type { WritableAtom } from "jotai";
|
||||||
|
import { createStore as createJotaiStore } from "jotai/vanilla";
|
||||||
|
|
||||||
|
export type { StateStore } from "@json-render/core";
|
||||||
|
|
||||||
|
type JotaiStore = ReturnType<typeof createJotaiStore>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for {@link jotaiStateStore}.
|
||||||
|
*/
|
||||||
|
export interface JotaiStateStoreOptions {
|
||||||
|
/** A writable atom that holds the json-render state model. */
|
||||||
|
atom: WritableAtom<StateModel, [StateModel], void>;
|
||||||
|
/**
|
||||||
|
* The Jotai store instance. Defaults to `createStore()` from `jotai/vanilla`.
|
||||||
|
* Pass your own if you use a `<Provider store={...}>` in your React tree.
|
||||||
|
*/
|
||||||
|
store?: JotaiStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a {@link StateStore} backed by a Jotai atom.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* import { atom } from "jotai";
|
||||||
|
* import { jotaiStateStore } from "@json-render/jotai";
|
||||||
|
*
|
||||||
|
* const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
*
|
||||||
|
* const store = jotaiStateStore({ atom: uiAtom });
|
||||||
|
*
|
||||||
|
* <StateProvider store={store}>...</StateProvider>
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example With a shared Jotai store:
|
||||||
|
* ```ts
|
||||||
|
* import { atom, createStore } from "jotai";
|
||||||
|
*
|
||||||
|
* const jStore = createStore();
|
||||||
|
* const uiAtom = atom<Record<string, unknown>>({ count: 0 });
|
||||||
|
*
|
||||||
|
* const store = jotaiStateStore({ atom: uiAtom, store: jStore });
|
||||||
|
*
|
||||||
|
* // In React:
|
||||||
|
* <JotaiProvider store={jStore}>
|
||||||
|
* <StateProvider store={store}>...</StateProvider>
|
||||||
|
* </JotaiProvider>
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function jotaiStateStore(options: JotaiStateStoreOptions): StateStore {
|
||||||
|
const stateAtom = options.atom;
|
||||||
|
const jStore = options.store ?? createJotaiStore();
|
||||||
|
|
||||||
|
return createStoreAdapter({
|
||||||
|
getSnapshot: () => jStore.get(stateAtom),
|
||||||
|
setSnapshot: (next) => jStore.set(stateAtom, next),
|
||||||
|
subscribe: (listener) => jStore.sub(stateAtom, listener),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "@internal/typescript-config/base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "tsup";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ["src/index.ts"],
|
||||||
|
format: ["cjs", "esm"],
|
||||||
|
dts: true,
|
||||||
|
sourcemap: true,
|
||||||
|
clean: true,
|
||||||
|
external: ["@json-render/core", "@json-render/core/store-utils", "jotai"],
|
||||||
|
});
|
||||||
@@ -214,6 +214,26 @@ const systemPrompt = catalog.prompt({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-native";
|
||||||
|
|
||||||
|
// Use the built-in store outside of React
|
||||||
|
const store = createStateStore({ count: 0 });
|
||||||
|
|
||||||
|
<StateProvider store={store}>
|
||||||
|
{children}
|
||||||
|
</StateProvider>
|
||||||
|
|
||||||
|
// Mutate from anywhere — React will re-render automatically:
|
||||||
|
store.set("/count", 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`.
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
| Hook | Purpose |
|
| Hook | Purpose |
|
||||||
@@ -227,3 +247,4 @@ const systemPrompt = catalog.prompt({
|
|||||||
| `useAction(name)` | Get a single action dispatch function |
|
| `useAction(name)` | Get a single action dispatch function |
|
||||||
| `useUIStream(options)` | Stream specs from an API endpoint |
|
| `useUIStream(options)` | Stream specs from an API endpoint |
|
||||||
| `createStandardActionHandlers(options)` | Create handlers for standard actions |
|
| `createStandardActionHandlers(options)` | Create handlers for standard actions |
|
||||||
|
| `createStateStore(initialState)` | Create a framework-agnostic in-memory `StateStore` |
|
||||||
|
|||||||
@@ -56,10 +56,11 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@json-render/core": "workspace:*"
|
"@json-render/core": "workspace:*",
|
||||||
|
"@internal/react-state": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
"react-native": "0.83.1",
|
"react-native": "0.83.1",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
@@ -67,7 +68,7 @@
|
|||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"react-native": ">=0.71.0",
|
"react-native": ">=0.71.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,8 @@
|
|||||||
import React, {
|
export {
|
||||||
createContext,
|
StateProvider,
|
||||||
useContext,
|
useStateStore,
|
||||||
useState,
|
useStateValue,
|
||||||
useCallback,
|
useStateBinding,
|
||||||
useMemo,
|
type StateContextValue,
|
||||||
useEffect,
|
type StateProviderProps,
|
||||||
useRef,
|
} from "@internal/react-state";
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { getByPath, setByPath, type StateModel } from "@json-render/core";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State context value
|
|
||||||
*/
|
|
||||||
export interface StateContextValue {
|
|
||||||
/** The current state model */
|
|
||||||
state: StateModel;
|
|
||||||
/** Get a value by path */
|
|
||||||
get: (path: string) => unknown;
|
|
||||||
/** Set a value by path */
|
|
||||||
set: (path: string, value: unknown) => void;
|
|
||||||
/** Update multiple values at once */
|
|
||||||
update: (updates: Record<string, unknown>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StateContext = createContext<StateContextValue | null>(null);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Props for StateProvider
|
|
||||||
*/
|
|
||||||
export interface StateProviderProps {
|
|
||||||
/** Initial state model */
|
|
||||||
initialState?: StateModel;
|
|
||||||
/** Callback when state changes */
|
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provider for state model context
|
|
||||||
*/
|
|
||||||
export function StateProvider({
|
|
||||||
initialState = {},
|
|
||||||
onStateChange,
|
|
||||||
children,
|
|
||||||
}: StateProviderProps) {
|
|
||||||
const [state, setStateInternal] = useState<StateModel>(initialState);
|
|
||||||
|
|
||||||
// Keep a ref to the latest state so `get` doesn't change on every update.
|
|
||||||
const stateRef = useRef(state);
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
// Track the serialized initialState to detect actual value changes (not just reference changes)
|
|
||||||
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
|
|
||||||
|
|
||||||
// Sync external state changes with internal state - only when values actually change
|
|
||||||
useEffect(() => {
|
|
||||||
const newJson = JSON.stringify(initialState);
|
|
||||||
if (newJson !== initialStateJsonRef.current) {
|
|
||||||
initialStateJsonRef.current = newJson;
|
|
||||||
if (initialState && Object.keys(initialState).length > 0) {
|
|
||||||
setStateInternal((prev) => ({ ...prev, ...initialState }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [initialState]);
|
|
||||||
|
|
||||||
// `get` uses a ref so it never changes identity — consumers that only
|
|
||||||
// need `get` won't re-render on every state change.
|
|
||||||
const get = useCallback(
|
|
||||||
(path: string) => getByPath(stateRef.current, path),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const set = useCallback(
|
|
||||||
(path: string, value: unknown) => {
|
|
||||||
setStateInternal((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
setByPath(next, path, value);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// Side effect after the state update
|
|
||||||
onStateChange?.(path, value);
|
|
||||||
},
|
|
||||||
[onStateChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
(updates: Record<string, unknown>) => {
|
|
||||||
const entries = Object.entries(updates);
|
|
||||||
setStateInternal((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const [path, value] of entries) {
|
|
||||||
setByPath(next, path, value);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// Side effects after the state update
|
|
||||||
for (const [path, value] of entries) {
|
|
||||||
onStateChange?.(path, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onStateChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useMemo<StateContextValue>(
|
|
||||||
() => ({
|
|
||||||
state,
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
update,
|
|
||||||
}),
|
|
||||||
[state, get, set, update],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to access the state context
|
|
||||||
*/
|
|
||||||
export function useStateStore(): StateContextValue {
|
|
||||||
const ctx = useContext(StateContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useStateStore must be used within a StateProvider");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get a value from the state model
|
|
||||||
*/
|
|
||||||
export function useStateValue<T>(path: string): T | undefined {
|
|
||||||
const { state } = useStateStore();
|
|
||||||
return getByPath(state, path) as T | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get and set a value from the state model (like useState).
|
|
||||||
*
|
|
||||||
* @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
|
|
||||||
* `useStateBinding` takes a raw state path string, while `useBoundProp` works
|
|
||||||
* with the renderer's `bindings` map and supports both `$bindState` and
|
|
||||||
* `$bindItem` expressions.
|
|
||||||
*/
|
|
||||||
export function useStateBinding<T>(
|
|
||||||
path: string,
|
|
||||||
): [T | undefined, (value: T) => void] {
|
|
||||||
const { state, set } = useStateStore();
|
|
||||||
const value = getByPath(state, path) as T | undefined;
|
|
||||||
const setValue = useCallback(
|
|
||||||
(newValue: T) => set(path, newValue),
|
|
||||||
[path, set],
|
|
||||||
);
|
|
||||||
return [value, setValue];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export {
|
|||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
// Core types (re-exported for convenience)
|
// Core types (re-exported for convenience)
|
||||||
export type { Spec } from "@json-render/core";
|
export type { Spec, StateStore } from "@json-render/core";
|
||||||
|
export { createStateStore } from "@json-render/core";
|
||||||
|
|
||||||
// Catalog-aware types for React Native
|
// Catalog-aware types for React Native
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ActionBinding,
|
ActionBinding,
|
||||||
Catalog,
|
Catalog,
|
||||||
SchemaDefinition,
|
SchemaDefinition,
|
||||||
|
StateStore,
|
||||||
} from "@json-render/core";
|
} from "@json-render/core";
|
||||||
import {
|
import {
|
||||||
resolveElementProps,
|
resolveElementProps,
|
||||||
@@ -414,7 +415,12 @@ export interface JSONUIProviderProps {
|
|||||||
* Custom components are merged with (and override) standard components.
|
* Custom components are merged with (and override) standard components.
|
||||||
*/
|
*/
|
||||||
registry?: ComponentRegistry;
|
registry?: ComponentRegistry;
|
||||||
/** Initial state model */
|
/**
|
||||||
|
* External store (controlled mode). When provided, `initialState` and
|
||||||
|
* `onStateChange` are ignored.
|
||||||
|
*/
|
||||||
|
store?: StateStore;
|
||||||
|
/** Initial state model (uncontrolled mode) */
|
||||||
initialState?: Record<string, unknown>;
|
initialState?: Record<string, unknown>;
|
||||||
/** Action handlers */
|
/** Action handlers */
|
||||||
handlers?: Record<
|
handlers?: Record<
|
||||||
@@ -428,8 +434,8 @@ export interface JSONUIProviderProps {
|
|||||||
string,
|
string,
|
||||||
(value: unknown, args?: Record<string, unknown>) => boolean
|
(value: unknown, args?: Record<string, unknown>) => boolean
|
||||||
>;
|
>;
|
||||||
/** Callback when state changes */
|
/** Callback when state changes (uncontrolled mode) */
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -438,6 +444,7 @@ export interface JSONUIProviderProps {
|
|||||||
*/
|
*/
|
||||||
export function JSONUIProvider({
|
export function JSONUIProvider({
|
||||||
registry,
|
registry,
|
||||||
|
store,
|
||||||
initialState,
|
initialState,
|
||||||
handlers,
|
handlers,
|
||||||
navigate,
|
navigate,
|
||||||
@@ -446,7 +453,11 @@ export function JSONUIProvider({
|
|||||||
children,
|
children,
|
||||||
}: JSONUIProviderProps) {
|
}: JSONUIProviderProps) {
|
||||||
return (
|
return (
|
||||||
<StateProvider initialState={initialState} onStateChange={onStateChange}>
|
<StateProvider
|
||||||
|
store={store}
|
||||||
|
initialState={initialState}
|
||||||
|
onStateChange={onStateChange}
|
||||||
|
>
|
||||||
<VisibilityProvider>
|
<VisibilityProvider>
|
||||||
<ActionProvider handlers={handlers} navigate={navigate}>
|
<ActionProvider handlers={handlers} navigate={navigate}>
|
||||||
<ValidationProvider customFunctions={validationFunctions}>
|
<ValidationProvider customFunctions={validationFunctions}>
|
||||||
@@ -636,12 +647,17 @@ type DefineRegistryActionFn = (
|
|||||||
export interface CreateRendererProps {
|
export interface CreateRendererProps {
|
||||||
/** The spec to render (AI-generated JSON) */
|
/** The spec to render (AI-generated JSON) */
|
||||||
spec: Spec | null;
|
spec: Spec | null;
|
||||||
/** State context for dynamic values */
|
/**
|
||||||
|
* External store (controlled mode). When provided, `state` and
|
||||||
|
* `onStateChange` are ignored.
|
||||||
|
*/
|
||||||
|
store?: StateStore;
|
||||||
|
/** State context for dynamic values (uncontrolled mode) */
|
||||||
state?: Record<string, unknown>;
|
state?: Record<string, unknown>;
|
||||||
/** Action handler */
|
/** Action handler */
|
||||||
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
onAction?: (actionName: string, params?: Record<string, unknown>) => void;
|
||||||
/** Callback when state changes (e.g., from form inputs) */
|
/** Callback when state changes (uncontrolled mode) */
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
|
||||||
/** Whether the spec is currently loading/streaming */
|
/** Whether the spec is currently loading/streaming */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Fallback component for unknown types */
|
/** Fallback component for unknown types */
|
||||||
@@ -691,6 +707,7 @@ export function createRenderer<
|
|||||||
// Return the renderer component
|
// Return the renderer component
|
||||||
return function CatalogRenderer({
|
return function CatalogRenderer({
|
||||||
spec,
|
spec,
|
||||||
|
store,
|
||||||
state,
|
state,
|
||||||
onAction,
|
onAction,
|
||||||
onStateChange,
|
onStateChange,
|
||||||
@@ -715,7 +732,11 @@ export function createRenderer<
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StateProvider initialState={state} onStateChange={onStateChange}>
|
<StateProvider
|
||||||
|
store={store}
|
||||||
|
initialState={state}
|
||||||
|
onStateChange={onStateChange}
|
||||||
|
>
|
||||||
<VisibilityProvider>
|
<VisibilityProvider>
|
||||||
<ActionProvider handlers={actionHandlers}>
|
<ActionProvider handlers={actionHandlers}>
|
||||||
<ValidationProvider>
|
<ValidationProvider>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": "@repo/typescript-config/react-library.json",
|
"extends": "@internal/typescript-config/react-library.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src"
|
"rootDir": "src"
|
||||||
|
|||||||
@@ -154,6 +154,19 @@ All render functions accept an optional second argument with:
|
|||||||
- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
|
- `state` - Initial state for `$state` / `$cond` dynamic prop resolution
|
||||||
- `handlers` - Action handlers
|
- `handlers` - Action handlers
|
||||||
|
|
||||||
|
## External Store (Controlled Mode)
|
||||||
|
|
||||||
|
For full control over state, pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer`. When `store` is provided, `initialState` and `onStateChange` are ignored and the store is the single source of truth:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createStateStore, type StateStore } from "@json-render/react-pdf";
|
||||||
|
|
||||||
|
const store = createStateStore({ invoice: { total: 100 } });
|
||||||
|
|
||||||
|
// Mutate from anywhere — components re-render automatically:
|
||||||
|
store.set("/invoice/total", 200);
|
||||||
|
```
|
||||||
|
|
||||||
## Server-Safe Import
|
## Server-Safe Import
|
||||||
|
|
||||||
Import schema and catalog definitions without pulling in React:
|
Import schema and catalog definitions without pulling in React:
|
||||||
|
|||||||
@@ -61,17 +61,18 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@json-render/core": "workspace:*",
|
"@json-render/core": "workspace:*",
|
||||||
|
"@internal/react-state": "workspace:*",
|
||||||
"@react-pdf/renderer": "^4.3.2"
|
"@react-pdf/renderer": "^4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@repo/typescript-config": "workspace:*",
|
"@internal/typescript-config": "workspace:*",
|
||||||
"@types/react": "19.2.3",
|
"@types/react": "19.2.3",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^19.0.0",
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
"zod": "^4.0.0"
|
"zod": "^4.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
StateProvider,
|
||||||
|
useStateStore,
|
||||||
|
useStateValue,
|
||||||
|
useStateBinding,
|
||||||
|
} from "./state";
|
||||||
|
|
||||||
|
describe("state re-exports (smoke test)", () => {
|
||||||
|
it("StateProvider + useStateStore round-trip", () => {
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStateStore(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.get("/count")).toBe(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.set("/count", 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.state.count).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useStateValue reads from state", () => {
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStateValue("/name"), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useStateBinding returns value and setter", () => {
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
|
||||||
|
|
||||||
|
const [value, setValue] = result.current;
|
||||||
|
expect(value).toBe(1);
|
||||||
|
expect(typeof setValue).toBe("function");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,117 +1,8 @@
|
|||||||
import React, {
|
export {
|
||||||
createContext,
|
StateProvider,
|
||||||
useContext,
|
useStateStore,
|
||||||
useState,
|
useStateValue,
|
||||||
useCallback,
|
useStateBinding,
|
||||||
useMemo,
|
type StateContextValue,
|
||||||
useEffect,
|
type StateProviderProps,
|
||||||
useRef,
|
} from "@internal/react-state";
|
||||||
type ReactNode,
|
|
||||||
} from "react";
|
|
||||||
import { getByPath, setByPath, type StateModel } from "@json-render/core";
|
|
||||||
|
|
||||||
export interface StateContextValue {
|
|
||||||
state: StateModel;
|
|
||||||
get: (path: string) => unknown;
|
|
||||||
set: (path: string, value: unknown) => void;
|
|
||||||
update: (updates: Record<string, unknown>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StateContext = createContext<StateContextValue | null>(null);
|
|
||||||
|
|
||||||
export interface StateProviderProps {
|
|
||||||
initialState?: StateModel;
|
|
||||||
onStateChange?: (path: string, value: unknown) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StateProvider({
|
|
||||||
initialState = {},
|
|
||||||
onStateChange,
|
|
||||||
children,
|
|
||||||
}: StateProviderProps) {
|
|
||||||
const [state, setStateInternal] = useState<StateModel>(initialState);
|
|
||||||
|
|
||||||
const stateRef = useRef(state);
|
|
||||||
stateRef.current = state;
|
|
||||||
|
|
||||||
const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const newJson = JSON.stringify(initialState);
|
|
||||||
if (newJson !== initialStateJsonRef.current) {
|
|
||||||
initialStateJsonRef.current = newJson;
|
|
||||||
if (initialState && Object.keys(initialState).length > 0) {
|
|
||||||
setStateInternal((prev) => ({ ...prev, ...initialState }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [initialState]);
|
|
||||||
|
|
||||||
const get = useCallback(
|
|
||||||
(path: string) => getByPath(stateRef.current, path),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const set = useCallback(
|
|
||||||
(path: string, value: unknown) => {
|
|
||||||
setStateInternal((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
setByPath(next, path, value);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
onStateChange?.(path, value);
|
|
||||||
},
|
|
||||||
[onStateChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const update = useCallback(
|
|
||||||
(updates: Record<string, unknown>) => {
|
|
||||||
const entries = Object.entries(updates);
|
|
||||||
setStateInternal((prev) => {
|
|
||||||
const next = { ...prev };
|
|
||||||
for (const [path, value] of entries) {
|
|
||||||
setByPath(next, path, value);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
for (const [path, value] of entries) {
|
|
||||||
onStateChange?.(path, value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onStateChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const value = useMemo<StateContextValue>(
|
|
||||||
() => ({ state, get, set, update }),
|
|
||||||
[state, get, set, update],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateContext.Provider value={value}>{children}</StateContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStateStore(): StateContextValue {
|
|
||||||
const ctx = useContext(StateContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error("useStateStore must be used within a StateProvider");
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStateValue<T>(path: string): T | undefined {
|
|
||||||
const { state } = useStateStore();
|
|
||||||
return getByPath(state, path) as T | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStateBinding<T>(
|
|
||||||
path: string,
|
|
||||||
): [T | undefined, (value: T) => void] {
|
|
||||||
const { state, set } = useStateStore();
|
|
||||||
const value = getByPath(state, path) as T | undefined;
|
|
||||||
const setValue = useCallback(
|
|
||||||
(newValue: T) => set(path, newValue),
|
|
||||||
[path, set],
|
|
||||||
);
|
|
||||||
return [value, setValue];
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user