Compare commits

..
Author SHA1 Message Date
github-actions[bot] e9ea9c782b chore: version packages (#80)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-07 12:31:01 -06:00
Chris Tate dd17549ee8 next release (#79) 2026-02-07 12:17:32 -06:00
Chris Tate 1eb6212dd7 fix json patch (#78) 2026-02-07 12:09:53 -06:00
Chris Tate 711e79b069 remove key/parentKey from flat specs (#77) 2026-02-07 11:46:16 -06:00
github-actions[bot] f3611860a7 chore: version packages (#75)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 18:02:12 -06:00
Chris Tate 61ee8e5291 changeset: include remove op in system prompt (#74) 2026-02-06 17:58:50 -06:00
Chris Tate d38b281e5e include remove op in system prompt (#73) 2026-02-06 17:56:03 -06:00
Chris Tate 39fcc192e9 fix stream docs (#72) 2026-02-06 14:48:32 -06:00
github-actions[bot] 96c7a452b3 chore: version packages (#71)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-06 14:33:12 -06:00
Chris Tate 844f18d1ee fix deps (#70) 2026-02-06 14:32:10 -06:00
Chris Tate 54bce097a5 add defineRegistry function (#67) 2026-02-06 14:30:06 -06:00
Chris Tate e2cb239c88 fix version (#69) 2026-02-06 14:28:38 -06:00
Chris Tate 90c59c674d update registry page (#68) 2026-02-06 14:27:07 -06:00
Chris Tate bba38727e8 ignore private packages (#66)
* ignore private packages

* fix ignore

* better ignore
2026-02-06 14:19:34 -06:00
Chris Tate 1d547b579c changeset (#65) 2026-02-06 14:12:00 -06:00
Chris Tate 795cdaac56 defineRegistry (#64)
* defineRegistry

* update dashboard example
2026-02-06 13:56:48 -06:00
Chris Tate 4ae049f218 stripe app (#62)
* stripe-app example

* fix lint

* add readme

* fix lint
2026-02-05 12:53:58 -06:00
57 changed files with 3007 additions and 1991 deletions
+13
View File
@@ -0,0 +1,13 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in the repository](https://github.com/changesets/changesets).
## Adding a changeset
To add a changeset, run `pnpm changeset` in the root of the repository. This will prompt you to select
which packages have changed and what type of version bump (major, minor, or patch) should be applied.
All `@json-render/*` packages are versioned together -- a changeset for any one of them will bump all
packages to the same version.
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@json-render/core",
"@json-render/react",
"@json-render/remotion",
"@json-render/codegen"
]
],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"privatePackages": {
"version": false,
"tag": false
}
}
+47
View File
@@ -0,0 +1,47 @@
name: Release
on:
push:
branches:
- main
workflow_dispatch:
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
pull-requests: write
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Create Release Pull Request or Publish
uses: changesets/action@v1
with:
version: pnpm ci:version
publish: pnpm ci:publish
title: "chore: version packages"
commit: "chore: version packages"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_VERCEL_TOKEN_ELEVATED }}
+27 -31
View File
@@ -56,45 +56,39 @@ const catalog = defineCatalog(schema, {
});
```
### 2. Register Component Implementations
### 2. Define Your Components
```tsx
import { defineComponents } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{children}
</div>
),
Metric: ({ props }) => (
<div className="metric">
<span>{props.label}</span>
<span>{format(props.value, props.format)}</span>
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
```
### 3. Render AI-Generated Specs
```tsx
import { Renderer } from "@json-render/react";
function Dashboard({ spec }) {
return (
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
);
return <Renderer spec={spec} registry={registry} />;
}
```
@@ -115,7 +109,7 @@ function Dashboard({ spec }) {
### React (UI)
```tsx
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { schema } from "@json-render/react";
// Element tree spec format
@@ -129,7 +123,9 @@ const spec = {
}
};
<Renderer spec={spec} catalog={catalog} components={components} />
// defineRegistry creates a type-safe component registry
const { registry } = defineRegistry(catalog, { components });
<Renderer spec={spec} registry={registry} />
```
### Remotion (Video)
+19 -5
View File
@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{}}
{"op":"set","path":"/root/type","value":"Card"}\`;
const jsonl = \`{"op":"add","path":"/root","value":{}}
{"op":"add","path":"/root/type","value":"Card"}\`;
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
@@ -285,22 +285,36 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
} from '@json-render/core';
// Parse a single line
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
// Apply patch to object (mutates in place)
const obj = {};
applySpecStreamPatch(obj, patch);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
<p className="text-sm text-muted-foreground mb-4">
Fully compliant with{" "}
<a
href="https://datatracker.ietf.org/doc/html/rfc6902"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
RFC 6902
</a>
:
</p>
<Code lang="typescript">{`interface SpecStreamLine {
op: 'set' | 'add' | 'replace' | 'remove';
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
path: string;
value?: unknown;
value?: unknown; // Required for add, replace, test
from?: string; // Required for move, copy
}
interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T;
getPatches(): SpecStreamLine[];
reset(): void;
}`}</Code>
+35 -20
View File
@@ -43,51 +43,66 @@ interface AuthState {
type ValidatorFn = (value: unknown, args?: object) => boolean | Promise<boolean>;`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">createRenderer</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Factory function to create a pre-configured renderer component.
Create a type-safe component registry from a catalog. Components receive{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>,{" "}
<code className="text-foreground">onAction</code>, and{" "}
<code className="text-foreground">loading</code> with catalog-inferred
types.
</p>
<Code lang="tsx">{`import { createRenderer } from '@json-render/react';
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
const MyRenderer = createRenderer({
registry: componentRegistry,
data?: initialData,
actionHandlers?: actionHandlerMap,
auth?: authState,
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
),
},
});
// Usage
<MyRenderer spec={spec} loading={isStreaming} />`}</Code>
// Pass to <Renderer>
<Renderer spec={spec} registry={registry} />`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Components</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">Renderer</h3>
<Code lang="tsx">{`<Renderer
spec={Spec} // The UI spec to render
registry={Registry} // Component registry
registry={Registry} // Component registry (from defineRegistry)
loading={boolean} // Optional loading state
fallback={Component} // Optional fallback for unknown types
/>
type Registry = Record<string, React.ComponentType<ComponentProps>>;
type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Code>
interface ComponentProps<T = Record<string, unknown>> {
props: T; // Component props from spec
<h3 className="text-lg font-semibold mt-8 mb-4">
Component Props (via defineRegistry)
</h3>
<Code lang="tsx">{`interface ComponentContext<P> {
props: P; // Typed props from catalog
children?: React.ReactNode; // Rendered children (for slot components)
onAction?: (action: { name: string; params?: object }) => void;
loading?: boolean;
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Hooks</h2>
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const {
spec, // Spec - current UI state
spec, // Spec | null - current UI state
isStreaming, // boolean - true while streaming
error, // Error | null
send, // (prompt: string) => void
abort, // () => void
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void>
clear, // () => void - reset spec and error
} = useUIStream({
api: string, // API endpoint URL
onChunk?: (chunk: string) => void, // Called for each chunk
onFinish?: (spec: Spec) => void, // Called when streaming completes
api: string, // API endpoint URL
onComplete?: (spec: Spec) => void, // Called when streaming completes
onError?: (error: Error) => void, // Called when an error occurs
});`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
-16
View File
@@ -275,11 +275,9 @@ export const openapiCatalog = createCatalog({
}
interface SpecElement {
key: string;
type: string;
props: Record<string, unknown>;
children: string[];
parentKey: string;
}
function schemaToSpec(
@@ -295,7 +293,6 @@ function schemaToSpec(
if (schema.enum) {
elements.set(key, {
key,
type: 'EnumField',
props: {
name,
@@ -306,11 +303,9 @@ function schemaToSpec(
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'string') {
elements.set(key, {
key,
type: 'StringField',
props: {
name,
@@ -323,11 +318,9 @@ function schemaToSpec(
defaultValue: schema.default as string,
},
children: [],
parentKey,
});
} else if (schema.type === 'integer' || schema.type === 'number') {
elements.set(key, {
key,
type: 'NumberField',
props: {
name,
@@ -340,11 +333,9 @@ function schemaToSpec(
defaultValue: schema.default as number,
},
children: [],
parentKey,
});
} else if (schema.type === 'boolean') {
elements.set(key, {
key,
type: 'BooleanField',
props: {
name,
@@ -353,7 +344,6 @@ function schemaToSpec(
defaultValue: schema.default as boolean,
},
children: [],
parentKey,
});
} else if (schema.type === 'array' && schema.items) {
const childKeys: string[] = [];
@@ -361,7 +351,6 @@ function schemaToSpec(
childKeys.push(itemKey);
elements.set(key, {
key,
type: 'ArrayField',
props: {
name,
@@ -369,7 +358,6 @@ function schemaToSpec(
description: schema.description,
},
children: childKeys,
parentKey,
});
} else if (schema.type === 'object' && schema.properties) {
const childKeys: string[] = [];
@@ -386,7 +374,6 @@ function schemaToSpec(
}
elements.set(key, {
key,
type: 'ObjectField',
props: {
name,
@@ -394,7 +381,6 @@ function schemaToSpec(
description: schema.description,
},
children: childKeys,
parentKey,
});
}
@@ -428,7 +414,6 @@ export function operationToSpec(
}
elements.set(rootKey, {
key: rootKey,
type: 'Form',
props: {
operationId,
@@ -438,7 +423,6 @@ export function operationToSpec(
description,
},
children: childKeys,
parentKey: '',
});
return {
+34 -31
View File
@@ -61,39 +61,42 @@ export const catalog = defineCatalog(schema, {
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
2. Create your components and actions
2. Define your components
</h2>
<p className="text-sm text-muted-foreground mb-4">
Define React components that render each catalog type. Each component
receives <code className="text-foreground">props</code>,{" "}
Use <code className="text-foreground">defineRegistry</code> to map
catalog types to React components. Each component receives type-safe{" "}
<code className="text-foreground">props</code>,{" "}
<code className="text-foreground">children</code>, and{" "}
<code className="text-foreground">onAction</code>:
</p>
<Code lang="tsx">{`// lib/components.tsx
import { defineComponents } from '@json-render/react';
<Code lang="tsx">{`// lib/registry.tsx
import { defineRegistry } from '@json-render/react';
import { catalog } from './catalog';
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.(props.action)}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="p-4 border rounded-lg">
<h2 className="font-bold">{props.title}</h2>
{props.description && (
<p className="text-gray-600">{props.description}</p>
)}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button
className="px-4 py-2 bg-blue-500 text-white rounded"
onClick={() => onAction?.({ name: props.action })}
>
{props.label}
</button>
),
Text: ({ props }) => (
<p>{props.content}</p>
),
},
});`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">
@@ -123,14 +126,14 @@ export async function POST(req: Request) {
<h2 className="text-xl font-semibold mt-12 mb-4">4. Render the UI</h2>
<p className="text-sm text-muted-foreground mb-4">
Use the providers and renderer to display AI-generated UI:
Use providers and the <code className="text-foreground">Renderer</code>{" "}
with your registry to display AI-generated UI:
</p>
<Code lang="tsx">{`// app/page.tsx
'use client';
import { DataProvider, ActionProvider, VisibilityProvider, Renderer, useUIStream } from '@json-render/react';
import { catalog } from '@/lib/catalog';
import { components } from '@/lib/components';
import { Renderer, DataProvider, ActionProvider, VisibilityProvider, useUIStream } from '@json-render/react';
import { registry } from '@/lib/registry';
export default function Page() {
const { spec, isStreaming, send } = useUIStream({
@@ -162,7 +165,7 @@ export default function Page() {
</form>
<div className="mt-8">
<Renderer spec={spec} catalog={catalog} components={components} loading={isStreaming} />
<Renderer spec={spec} registry={registry} loading={isStreaming} />
</div>
</ActionProvider>
</VisibilityProvider>
+139 -112
View File
@@ -14,82 +14,86 @@ export default function RegistryPage() {
life.
</p>
{/* Components Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
{/* defineRegistry */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2>
<p className="text-sm text-muted-foreground mb-4">
Create a registry that maps catalog component types to React components:
Use <code>defineRegistry</code> to create a type-safe registry from your
catalog. Pass your components, actions, or both in a single call:
</p>
<Code lang="tsx">{`import { useAction } from '@json-render/react';
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react';
import { myCatalog } from './catalog';
const registry = {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props }) => {
const executeAction = useAction(props.action);
return (
<button onClick={() => executeAction({})}>
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
</button>
);
),
},
};`}</Code>
actions: {
submit_form: async (params, setData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify(params),
});
const result = await res.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
},
});`}</Code>
<p className="text-sm text-muted-foreground mt-4 mb-4">
The returned object contains:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground mb-4 space-y-1">
<li>
<code>registry</code> - component registry for{" "}
<code>{"<Renderer />"}</code>
</li>
<li>
<code>handlers</code> - factory for ActionProvider-compatible handlers
</li>
<li>
<code>executeAction</code> - imperative action dispatch (for use
outside the React tree)
</li>
</ul>
{/* Component Props */}
<h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4">
Each component receives these props:
Each component in the registry receives a <code>ComponentContext</code>{" "}
object:
</p>
<Code lang="typescript">{`interface ComponentProps<T = Record<string, unknown>> {
props: T; // Component props from the spec
<Code lang="typescript">{`interface ComponentContext {
props: T; // Type-safe props from your catalog
children?: React.ReactNode; // Rendered children (for slot components)
}
onAction?: (action: ActionTrigger) => void; // Dispatch an action
loading?: boolean; // Whether the renderer is in a loading state
}`}</Code>
// Type-safe props by extracting from your catalog
type CardProps = ComponentProps<{
title: string;
description: string | null;
}>;
// Use hooks for actions and data within components
import { useAction, useDataValue } from '@json-render/react';`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks to read and write data:
<p className="text-sm text-muted-foreground mt-4 mb-4">
Props are automatically inferred from your catalog, so{" "}
<code>props.title</code> is typed as <code>string</code> if your catalog
defines it that way.
</p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
const Metric = ({ props }) => {
// Read-only value from data context
const value = useDataValue(props.valuePath);
return (
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
};
const TextField = ({ props }) => {
// Two-way binding to data context
const [value, setValue] = useDataBinding(props.valuePath);
return (
<input
value={value || ''}
onChange={(e) => setValue(e.target.value)}
placeholder={props.placeholder}
/>
);
};`}</Code>
{/* Actions Section */}
{/* Action Handlers */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
<p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by
@@ -117,9 +121,6 @@ const catalog = defineCatalog(schema, {
export_data: {
params: z.object({
format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().nullable(),
}).nullable(),
}),
},
navigate: {
@@ -130,78 +131,104 @@ const catalog = defineCatalog(schema, {
},
});`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">ActionProvider</h3>
<h3 className="text-lg font-medium mt-8 mb-3">
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4">
Provide action handlers to your app:
Action handlers receive <code>(params, setData, data)</code> and are
defined inside <code>defineRegistry</code>:
</p>
<Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
function App() {
const handlers = {
submit_form: async (params) => {
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, {
actions: {
submit_form: async (params, setData) => {
const response = await fetch('/api/submit', {
method: 'POST',
body: JSON.stringify({ formId: params.formId }),
});
return response.json();
const result = await response.json();
setData((prev) => ({ ...prev, formResult: result }));
},
export_data: async (params) => {
const blob = await generateExport(params.format, params.filters);
const blob = await generateExport(params.format);
downloadBlob(blob, \`export.\${params.format}\`);
},
navigate: (params) => {
window.location.href = params.url;
},
};
},
});`}</Code>
{/* Using Data Binding */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using Data Binding</h2>
<p className="text-sm text-muted-foreground mb-4">
Use hooks inside your registry components to read and write data:
</p>
<Code lang="tsx">{`import { useData } from '@json-render/react';
import { getByPath } from '@json-render/core';
// Inside defineRegistry components:
Metric: ({ props }) => {
const { data } = useData();
const value = getByPath(data, props.valuePath);
return (
<ActionProvider handlers={handlers}>
{/* Your UI */}
</ActionProvider>
<div className="metric">
<span className="label">{props.label}</span>
<span className="value">{formatValue(value)}</span>
</div>
);
}`}</Code>
},
<h3 className="text-lg font-medium mt-8 mb-3">
Using Actions in Components
</h3>
<Code lang="tsx">{`import { useAction } from '@json-render/react';
TextField: ({ props }) => {
const { data, set } = useData();
const value = getByPath(data, props.valuePath) as string;
// Using the useAction hook (recommended)
const Button = ({ props }) => {
const executeAction = useAction(props.action);
return (
<button onClick={() => executeAction({})}>
{props.label}
</button>
<input
value={value || ''}
onChange={(e) => set(props.valuePath, e.target.value)}
placeholder={props.placeholder}
/>
);
};
// Or for standalone use
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
},`}</Code>
{/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<Code lang="tsx">{`import { Renderer, ActionProvider } from '@json-render/react';
<p className="text-sm text-muted-foreground mb-4">
Wire everything together with providers and the{" "}
<code>{"<Renderer />"}</code> component:
</p>
<Code lang="tsx">{`import { useMemo, useRef } from 'react';
import {
Renderer,
DataProvider,
VisibilityProvider,
ActionProvider,
} from '@json-render/react';
import { registry, handlers } from './registry';
function App({ spec, data, setData }) {
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
const actionHandlers = useMemo(
() => handlers(() => setDataRef.current, () => dataRef.current),
[],
);
function App() {
return (
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={uiSpec}
registry={registry}
/>
</ActionProvider>
<DataProvider initialData={data}>
<VisibilityProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</VisibilityProvider>
</DataProvider>
);
}`}</Code>
+4 -14
View File
@@ -98,25 +98,19 @@ export default function SchemasPage() {
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Dashboard" },
"children": ["text-1", "button-1"],
"parentKey": ""
"children": ["text-1", "button-1"]
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Welcome, $data.user.name" },
"children": [],
"parentKey": "card-1"
"children": []
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Click me" },
"children": [],
"parentKey": "card-1"
"children": []
}
}
}`}</Code>
@@ -129,11 +123,9 @@ export default function SchemasPage() {
structure:
</p>
<Code lang="typescript">{`interface Element {
key: string; // Unique identifier
type: string; // Component type from catalog
props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys
parentKey: string; // Parent element key (empty for root)
visible?: VisibilityRule; // Conditional display
}`}</Code>
@@ -143,14 +135,12 @@ export default function SchemasPage() {
<code className="text-foreground">$data</code> prefix in props:
</p>
<Code lang="json">{`{
"key": "greeting",
"type": "Text",
"props": {
"content": "$data.user.name",
"count": "$data.items.length"
},
"children": [],
"parentKey": "card-1"
"children": []
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
+20 -70
View File
@@ -50,18 +50,14 @@ export default function SpecsPage() {
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "Welcome" },
"children": ["text-1"],
"parentKey": ""
"children": ["text-1"]
},
"text-1": {
"key": "text-1",
"type": "Text",
"props": { "content": "Hello, $data.user.name!" },
"children": [],
"parentKey": "card-1"
"children": []
}
}
}`}</Code>
@@ -74,53 +70,39 @@ export default function SpecsPage() {
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "User Profile", "padding": "md" },
"children": ["row-1", "button-1"],
"parentKey": ""
"children": ["row-1", "button-1"]
},
"row-1": {
"key": "row-1",
"type": "Row",
"props": { "gap": "md" },
"children": ["avatar-1", "stack-1"],
"parentKey": "card-1"
"children": ["avatar-1", "stack-1"]
},
"avatar-1": {
"key": "avatar-1",
"type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"children": [],
"parentKey": "row-1"
"children": []
},
"stack-1": {
"key": "stack-1",
"type": "Stack",
"props": { "gap": "sm" },
"children": ["name-text", "email-text"],
"parentKey": "row-1"
"children": ["name-text", "email-text"]
},
"name-text": {
"key": "name-text",
"type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" },
"children": [],
"parentKey": "stack-1"
"children": []
},
"email-text": {
"key": "email-text",
"type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" },
"children": [],
"parentKey": "stack-1"
"children": []
},
"button-1": {
"key": "button-1",
"type": "Button",
"props": { "label": "Edit Profile" },
"children": [],
"parentKey": "card-1"
"children": []
}
}
}`}</Code>
@@ -133,21 +115,16 @@ export default function SpecsPage() {
"root": "page",
"elements": {
"page": {
"key": "page",
"type": "Page",
"props": {},
"children": ["header", "hero", "features", "footer"],
"parentKey": ""
"children": ["header", "hero", "features", "footer"]
},
"header": {
"key": "header",
"type": "Header",
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
"children": [],
"parentKey": "page"
"children": []
},
"hero": {
"key": "hero",
"type": "Hero",
"props": {
"title": "Build UIs with JSON",
@@ -155,43 +132,32 @@ export default function SpecsPage() {
"ctaLabel": "Get Started",
"ctaHref": "/docs"
},
"children": [],
"parentKey": "page"
"children": []
},
"features": {
"key": "features",
"type": "Features",
"props": { "columns": 3 },
"children": ["feature-1", "feature-2", "feature-3"],
"parentKey": "page"
"children": ["feature-1", "feature-2", "feature-3"]
},
"feature-1": {
"key": "feature-1",
"type": "Feature",
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
"children": [],
"parentKey": "features"
"children": []
},
"feature-2": {
"key": "feature-2",
"type": "Feature",
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
"children": [],
"parentKey": "features"
"children": []
},
"feature-3": {
"key": "feature-3",
"type": "Feature",
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
"children": [],
"parentKey": "features"
"children": []
},
"footer": {
"key": "footer",
"type": "Footer",
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
"children": [],
"parentKey": "page"
"children": []
}
}
}`}</Code>
@@ -209,11 +175,9 @@ export default function SpecsPage() {
"root": "card-1",
"elements": {
"card-1": {
"key": "card-1",
"type": "Card",
"props": { "title": "My Card" },
"children": ["text-1"],
"parentKey": ""
"children": ["text-1"]
},
"text-1": { ... }
}
@@ -224,17 +188,11 @@ export default function SpecsPage() {
Each element in the map has a consistent shape:
</p>
<Code lang="json">{`{
"key": "unique-id",
"type": "ComponentName",
"props": { "label": "Hello" },
"children": ["child-1", "child-2"],
"parentKey": "parent-id"
"children": ["child-1", "child-2"]
}`}</Code>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4">
<li>
<code className="text-foreground">key</code> — Unique identifier for
this element
</li>
<li>
<code className="text-foreground">type</code> — Component type from
your catalog
@@ -246,10 +204,6 @@ export default function SpecsPage() {
<code className="text-foreground">children</code> — Array of child
element keys
</li>
<li>
<code className="text-foreground">parentKey</code> — Key of parent
element (empty string for root)
</li>
</ul>
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
@@ -258,15 +212,13 @@ export default function SpecsPage() {
<code className="text-foreground">$data</code> paths:
</p>
<Code lang="json">{`{
"key": "metric-1",
"type": "Metric",
"props": {
"label": "Total Revenue",
"value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange"
},
"children": [],
"parentKey": "dashboard"
"children": []
}`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
@@ -275,13 +227,11 @@ export default function SpecsPage() {
<code className="text-foreground">visible</code> property:
</p>
<Code lang="json">{`{
"key": "alert-1",
"type": "Alert",
"props": {
"message": "You have unsaved changes"
},
"children": [],
"parentKey": "form",
"visible": {
"path": "$data.form.isDirty",
"operator": "eq",
+48 -22
View File
@@ -18,9 +18,10 @@ export default function StreamingPage() {
format where each line is a JSON patch operation that progressively
builds your spec:
</p>
<Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
<Code lang="json">{`{"op":"add","path":"/root","value":"root"}
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4">
@@ -34,34 +35,55 @@ function App() {
isStreaming, // True while streaming
error, // Any error that occurred
send, // Function to start generation
abort, // Function to cancel streaming
clear, // Function to reset spec and error
} = useUIStream({
api: '/api/generate',
onChunk: (chunk) => {}, // Optional: called for each chunk
onFinish: (spec) => {}, // Optional: called when complete
onComplete: (spec) => {}, // Optional: called when streaming completes
onError: (error) => {}, // Optional: called when an error occurs
});
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
<h2 className="text-xl font-semibold mt-12 mb-4">
Patch Operations (RFC 6902)
</h2>
<p className="text-sm text-muted-foreground mb-4">
Supported operations:
SpecStream uses{" "}
<a
href="https://datatracker.ietf.org/doc/html/rfc6902"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
RFC 6902 JSON Patch
</a>{" "}
operations:
</p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li>
<code className="text-foreground">set</code> — Set the value at a path
(creates if needed)
<code className="text-foreground">add</code> — Add a value at a path
(creates or replaces for objects, inserts for arrays)
</li>
<li>
<code className="text-foreground">add</code> — Add to an array at a
path
<code className="text-foreground">remove</code> — Remove the value at
a path
</li>
<li>
<code className="text-foreground">replace</code> — Replace value at a
path
<code className="text-foreground">replace</code> — Replace an existing
value at a path
</li>
<li>
<code className="text-foreground">remove</code> — Remove value at a
path
<code className="text-foreground">move</code> — Move a value from one
path to another (requires{" "}
<code className="text-foreground">from</code>)
</li>
<li>
<code className="text-foreground">copy</code> — Copy a value from one
path to another (requires{" "}
<code className="text-foreground">from</code>)
</li>
<li>
<code className="text-foreground">test</code> — Assert that a value at
a path equals the given value
</li>
</ul>
@@ -112,8 +134,14 @@ export async function POST(req: Request) {
}`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2>
<p className="text-sm text-muted-foreground mb-4">
Calling <code className="text-foreground">send</code> again
automatically aborts the previous request. Use{" "}
<code className="text-foreground">clear</code> to reset the spec and
error state:
</p>
<Code lang="tsx">{`function App() {
const { isStreaming, send, abort } = useUIStream({
const { isStreaming, send, clear } = useUIStream({
api: '/api/generate',
});
@@ -122,9 +150,7 @@ export async function POST(req: Request) {
<button onClick={() => send('Create dashboard')}>
Generate
</button>
{isStreaming && (
<button onClick={abort}>Cancel</button>
)}
<button onClick={clear}>Reset</button>
</div>
);
}`}</Code>
@@ -165,8 +191,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
</p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}}
{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
+11 -13
View File
@@ -127,15 +127,16 @@ export const catalog = createCatalog({
Constrained output that your components render natively.
</p>
<Code lang="json">{`{
"key": "dashboard",
"type": "Card",
"props": {
"title": "Revenue Dashboard",
"description": null
},
"children": [
{
"key": "revenue",
"root": "dashboard",
"elements": {
"dashboard": {
"type": "Card",
"props": {
"title": "Revenue Dashboard"
},
"children": ["revenue"]
},
"revenue": {
"type": "Metric",
"props": {
"label": "Total Revenue",
@@ -143,7 +144,7 @@ export const catalog = createCatalog({
"format": "currency"
}
}
]
}
}`}</Code>
</div>
</div>
@@ -170,13 +171,11 @@ export const catalog = createCatalog({
"root": "card",
"elements": {
"card": {
"key": "card",
"type": "Card",
"props": { "title": "Revenue" },
"children": ["metric", "chart"]
},
"metric": {
"key": "metric",
"type": "Metric",
"props": {
"label": "Total Revenue",
@@ -185,7 +184,6 @@ export const catalog = createCatalog({
}
},
"chart": {
"key": "chart",
"type": "Chart",
"props": {
"dataPath": "analytics/salesByRegion"
+3 -2
View File
@@ -62,8 +62,9 @@ USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
- To remove an element: {"op":"remove","path":"/elements/old-key"}
- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
+5 -20
View File
@@ -29,124 +29,109 @@ const SIMULATION_STAGES: SimulationStage[] = [
root: "card",
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: [],
},
},
},
stream: '{"op":"set","path":"/root","value":"card"}',
stream: '{"op":"add","path":"/root","value":"card"}',
},
{
tree: {
root: "card",
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
},
},
stream:
'{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
},
{
tree: {
root: "card",
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
},
},
stream:
'{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}',
},
{
tree: {
root: "card",
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
key: "message",
type: "Textarea",
props: { label: "Message", name: "message" },
},
},
},
stream:
'{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}',
},
{
tree: {
root: "card",
elements: {
card: {
key: "card",
type: "Card",
props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message", "submit"],
},
name: {
key: "name",
type: "Input",
props: { label: "Name", name: "name" },
},
email: {
key: "email",
type: "Input",
props: { label: "Email", name: "email" },
},
message: {
key: "message",
type: "Textarea",
props: { label: "Message", name: "message" },
},
submit: {
key: "submit",
type: "Button",
props: { label: "Send Message", variant: "primary" },
},
},
},
stream:
'{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
},
];
+1 -1
View File
@@ -13,7 +13,7 @@ import {
} from "lucide-react";
import { useUIStream, type Spec } from "@json-render/react";
import { DashboardRenderer } from "@/lib/render/renderer";
import { executeAction } from "@/lib/render/catalog/actions";
import { executeAction } from "@/lib/render/registry";
import { CodeHighlight } from "@/components/code-highlight";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -1,255 +0,0 @@
import { toast } from "sonner";
import { findFormValue } from "@json-render/core";
import {
type Actions,
type ActionFn,
type SetData,
type DataModel,
} from "@json-render/react";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Action Handlers - Type-safe with Catalog
// =============================================================================
export const actionHandlers: Actions<typeof dashboardCatalog> = {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as string | undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/mark-paid`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (!params?.vendor || !params?.category || params?.amount === undefined) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(`/api/v1/expenses/${params.expenseId}/approve`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
};
// =============================================================================
// Execute Action
// =============================================================================
type Catalog = typeof dashboardCatalog;
type CatalogActions = Catalog["data"]["actions"];
/**
* Execute an action by name with the given parameters.
*/
export async function executeAction(
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> {
const handler = actionHandlers[actionName as keyof CatalogActions];
if (handler) {
await (handler as ActionFn<Catalog, keyof CatalogActions>)(
params as never,
setData,
data,
);
} else {
console.log("Unknown action:", actionName, params);
toast(`Action: ${actionName}`);
}
}
@@ -1,771 +0,0 @@
"use client";
import { useData, type Components } from "@json-render/react";
import { getByPath } from "@json-render/core";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "../catalog";
// =============================================================================
// Components - Type-safe with Catalog
// =============================================================================
export const components: Components<typeof dashboardCatalog> = {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ?? "gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? <AvatarImage src={props.src} alt={props.alt ?? ""} /> : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink href="#" isActive={page === props.currentPage}>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ?? props.defaultValue ?? "";
return (
<RadioGroup value={value} onValueChange={(v) => set(props.valuePath, v)}>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
};
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
// Check for ISO date format: YYYY-MM-DDTHH:mm:ss
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
// Format as "Mon DD" (e.g., "Feb 04")
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
// Check if xKey contains date values
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
// If no aggregation, just format dates and return
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
// Group items by xKey (use date string for dates)
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
// Group by date (YYYY-MM-DD)
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
// Aggregate each group
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
// Sort keys (for dates, this will be chronological)
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
// avg
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
// Format label
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+998
View File
@@ -0,0 +1,998 @@
"use client";
import { toast } from "sonner";
import { findFormValue, getByPath } from "@json-render/core";
import { useData, defineRegistry } from "@json-render/react";
import {
Bar,
BarChart as RechartsBarChart,
CartesianGrid,
Line,
LineChart as RechartsLineChart,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/components/ui/chart";
// shadcn components
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Separator } from "@/components/ui/separator";
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent,
} from "@/components/ui/accordion";
import {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationLink,
} from "@/components/ui/pagination";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
TooltipProvider,
} from "@/components/ui/tooltip";
import { dashboardCatalog } from "./catalog";
// =============================================================================
// Registry
// =============================================================================
export const { registry, handlers, executeAction } = defineRegistry(
dashboardCatalog,
{
components: {
Stack: ({ props, children }) => {
const gapClass =
{ sm: "gap-2", md: "gap-4", lg: "gap-6" }[props.gap ?? "md"] ??
"gap-4";
return (
<div
className={`flex ${props.direction === "horizontal" ? "flex-row" : "flex-col"} ${gapClass}`}
>
{children}
</div>
);
},
Accordion: ({ props, children }) => (
<Accordion type={props.type ?? "single"} collapsible>
{children}
</Accordion>
),
AccordionItem: ({ props, children }) => (
<AccordionItem value={props.value}>
<AccordionTrigger>{props.title}</AccordionTrigger>
<AccordionContent>{children}</AccordionContent>
</AccordionItem>
),
Button: ({ props, onAction, loading }) => (
<Button
variant={props.variant ?? "default"}
disabled={loading || (props.disabled ?? false)}
onClick={() =>
onAction?.({
name: props.action,
params: props.actionParams ?? undefined,
})
}
>
{loading ? "..." : props.label}
</Button>
),
Input: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Input
type={props.type ?? "text"}
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Form: ({ props, children, onAction }) => (
<form
onSubmit={(e) => {
e.preventDefault();
onAction?.({
name: props.submitAction,
params: props.submitActionParams ?? undefined,
});
}}
className="flex flex-col gap-4"
>
{children}
</form>
),
Badge: ({ props }) => (
<Badge variant={props.variant ?? "default"}>{props.text}</Badge>
),
Alert: ({ props }) => (
<Alert variant={props.variant ?? "default"}>
<AlertTitle>{props.title}</AlertTitle>
{props.description ? (
<AlertDescription>{props.description}</AlertDescription>
) : null}
</Alert>
),
Separator: () => <Separator />,
Avatar: ({ props }) => (
<Avatar>
{props.src ? (
<AvatarImage src={props.src} alt={props.alt ?? ""} />
) : null}
<AvatarFallback>{props.fallback}</AvatarFallback>
</Avatar>
),
Checkbox: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Checkbox
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Dialog: ({ props, children }) => (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{props.title}</DialogTitle>
{props.description ? (
<DialogDescription>{props.description}</DialogDescription>
) : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
),
Drawer: ({ props, children }) => (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>{props.title}</DrawerTitle>
{props.description ? (
<DrawerDescription>{props.description}</DrawerDescription>
) : null}
</DrawerHeader>
<div className="p-4">{children}</div>
</DrawerContent>
</Drawer>
),
DropdownMenu: ({ props, onAction }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{props.items.map((item, i) => (
<DropdownMenuItem
key={i}
onClick={() =>
item.action
? onAction?.({
name: item.action,
params: item.actionParams ?? undefined,
})
: undefined
}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
),
Label: ({ props }) => (
<Label htmlFor={props.htmlFor ?? undefined}>{props.text}</Label>
),
Pagination: ({ props }) => {
const pages = Array.from({ length: props.totalPages }, (_, i) => i + 1);
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="#" />
</PaginationItem>
{pages.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={page === props.currentPage}
>
{page}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext href="#" />
</PaginationItem>
</PaginationContent>
</Pagination>
);
},
Popover: ({ props, children }) => (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline">{props.trigger}</Button>
</PopoverTrigger>
<PopoverContent>{children}</PopoverContent>
</Popover>
),
Progress: ({ props }) => (
<Progress value={props.value} max={props.max ?? 100} />
),
RadioGroup: ({ props }) => {
const { data, set } = useData();
const value =
(getByPath(data, props.valuePath) as string) ??
props.defaultValue ??
"";
return (
<RadioGroup
value={value}
onValueChange={(v) => set(props.valuePath, v)}
>
{props.options.map((option) => (
<div key={option.value} className="flex items-center gap-2">
<RadioGroupItem value={option.value} id={option.value} />
<Label htmlFor={option.value}>{option.label}</Label>
</div>
))}
</RadioGroup>
);
},
Select: ({ props }) => {
const { data, set } = useData();
const value = (getByPath(data, props.valuePath) as string) ?? "";
return (
<Select value={value} onValueChange={(v) => set(props.valuePath, v)}>
<SelectTrigger>
<SelectValue placeholder={props.placeholder ?? "Select..."} />
</SelectTrigger>
<SelectContent>
{props.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
},
Skeleton: ({ props }) => (
<Skeleton
className={`${props.width ?? "w-full"} ${props.height ?? "h-4"}`}
/>
),
Spinner: ({ props }) => {
const sizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
const size = sizes[props.size ?? "md"];
return (
<div
className={`${size} animate-spin rounded-full border-2 border-muted border-t-primary`}
/>
);
},
Switch: ({ props }) => {
const { data, set } = useData();
const checked =
(getByPath(data, props.valuePath) as boolean) ??
props.defaultChecked ??
false;
return (
<div className="flex items-center gap-2">
<Switch
id={props.valuePath}
checked={checked}
onCheckedChange={(value) => set(props.valuePath, value)}
/>
{props.label ? (
<Label htmlFor={props.valuePath}>{props.label}</Label>
) : null}
</div>
);
},
Tabs: ({ props, children }) => (
<Tabs defaultValue={props.defaultValue ?? props.tabs[0]?.value}>
<TabsList>
{props.tabs.map((tab) => (
<TabsTrigger key={tab.value} value={tab.value}>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
),
TabContent: ({ props, children }) => (
<TabsContent value={props.value}>{children}</TabsContent>
),
Textarea: ({ props }) => {
const { data, set } = useData();
return (
<div className="flex flex-col gap-2">
{props.label ? <Label>{props.label}</Label> : null}
<Textarea
value={(getByPath(data, props.valuePath) as string) ?? ""}
placeholder={props.placeholder ?? ""}
rows={props.rows ?? 3}
onChange={(e) => set(props.valuePath, e.target.value)}
/>
</div>
);
},
Tooltip: ({ props, children }) => (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent>{props.content}</TooltipContent>
</Tooltip>
</TooltipProvider>
),
// Heading is intentionally not rendered - widgets already have a title bar
Heading: () => null,
Text: ({ props }) => (
<p className={props.muted ? "text-muted-foreground" : ""}>
{props.content}
</p>
),
Table: ({ props, onAction }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const items: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: Array.isArray((rawData as Record<string, unknown>)?.items)
? ((rawData as Record<string, unknown>).items as Array<
Record<string, unknown>
>)
: [];
if (items.length === 0) {
return (
<div className="text-center py-4 text-muted-foreground">
{props.emptyMessage ?? "No data"}
</div>
);
}
const hasRowActions = props.rowActions && props.rowActions.length > 0;
return (
<Table>
<TableHeader>
<TableRow>
{props.columns.map((col) => (
<TableHead key={col.key}>{col.label}</TableHead>
))}
{hasRowActions ? <TableHead>Actions</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{items.map((item, i) => (
<TableRow key={i}>
{props.columns.map((col) => (
<TableCell key={col.key}>
{String(item[col.key] ?? "")}
</TableCell>
))}
{hasRowActions ? (
<TableCell>
<div className="flex gap-1">
{props.rowActions!.map((rowAction) => (
<Button
key={rowAction.action}
variant={rowAction.variant ?? "ghost"}
size="sm"
onClick={() =>
onAction?.({
name: rowAction.action,
params: { id: item.id as string },
})
}
>
{rowAction.label}
</Button>
))}
</div>
</TableCell>
) : null}
</TableRow>
))}
</TableBody>
</Table>
);
},
BarChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsBarChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Bar
dataKey={valueKey}
fill={`var(--color-${valueKey})`}
radius={4}
/>
</RechartsBarChart>
</ChartContainer>
</div>
);
},
LineChart: ({ props }) => {
const { data } = useData();
const path = props.dataPath.replace(/\./g, "/");
const rawData = getByPath(data, path);
const rawItems: Array<Record<string, unknown>> = Array.isArray(rawData)
? rawData
: Array.isArray((rawData as Record<string, unknown>)?.data)
? ((rawData as Record<string, unknown>).data as Array<
Record<string, unknown>
>)
: [];
// Process and aggregate data
const { items, valueKey } = processChartData(
rawItems,
props.xKey,
props.yKey,
props.aggregate,
);
const chartColor = props.color ?? "var(--chart-1)";
const chartConfig = {
[valueKey]: {
label: valueKey,
color: chartColor,
},
} satisfies ChartConfig;
if (items.length === 0) {
return (
<div className="w-full">
<div className="text-center py-4 text-muted-foreground">
No data available
</div>
</div>
);
}
return (
<div className="w-full">
<ChartContainer
config={chartConfig}
className="min-h-[200px] w-full"
style={{ height: props.height ?? 300 }}
>
<RechartsLineChart accessibilityLayer data={items}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent />} />
<Line
type="monotone"
dataKey={valueKey}
stroke={`var(--color-${valueKey})`}
strokeWidth={2}
dot={false}
/>
</RechartsLineChart>
</ChartContainer>
</div>
);
},
},
actions: {
viewCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
refreshCustomers: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.limit) queryParams.set("limit", String(params.limit));
if (params?.sort) queryParams.set("sort", String(params.sort));
const url = `/api/v1/customers${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const customers = await res.json();
setData((prev) => ({ ...prev, customers }));
},
createCustomer: async (params, setData, data) => {
const name = findFormValue("name", params, data) as string;
const email =
(findFormValue("email", params, data) as string) ||
`${name?.toLowerCase().replace(/\s+/g, ".")}@example.com`;
const phone = findFormValue("phone", params, data) as
| string
| undefined;
if (!name) {
toast.error("Customer name is required");
return;
}
try {
const res = await fetch("/api/v1/customers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, phone }),
});
const customer = await res.json();
if (res.ok) {
toast.success(`Customer "${customer.name}" created`);
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
toast.error(customer.error || "Failed to create customer");
}
} catch (err) {
console.error("Failed to create customer:", err);
toast.error("Failed to create customer");
}
},
deleteCustomer: async (params, setData, data) => {
const customerId =
findFormValue("customerId", params, data) ||
findFormValue("id", params, data);
if (!customerId) {
toast.error("Customer ID required");
return;
}
try {
const res = await fetch(`/api/v1/customers/${customerId}`, {
method: "DELETE",
});
if (res.ok) {
toast.success("Customer deleted");
const listRes = await fetch("/api/v1/customers");
const customers = await listRes.json();
setData((prev) => ({ ...prev, customers }));
} else {
const err = await res.json();
toast.error(err.error || "Failed to delete customer");
}
} catch (err) {
console.error("Failed to delete customer:", err);
toast.error("Failed to delete customer");
}
},
viewInvoices: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/invoices${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
refreshInvoices: async (_params, setData) => {
const res = await fetch("/api/v1/invoices");
const invoices = await res.json();
setData((prev) => ({ ...prev, invoices }));
},
createInvoice: async (params, setData) => {
if (!params?.customerId || !params?.dueDate) {
toast.error("Customer ID and due date required");
return;
}
try {
const res = await fetch("/api/v1/invoices", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const invoice = await res.json();
if (res.ok) {
toast.success("Invoice created");
const listRes = await fetch("/api/v1/invoices");
const invoices = await listRes.json();
setData((prev) => ({ ...prev, invoices }));
} else {
toast.error(invoice.error || "Failed to create invoice");
}
} catch (err) {
console.error("Failed to create invoice:", err);
toast.error("Failed to create invoice");
}
},
sendInvoice: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(`/api/v1/invoices/${params.invoiceId}/send`, {
method: "POST",
});
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice sent");
} else {
toast.error(result.error || "Failed to send invoice");
}
},
markInvoicePaid: async (params) => {
if (!params?.invoiceId) {
toast.error("Invoice ID required");
return;
}
const res = await fetch(
`/api/v1/invoices/${params.invoiceId}/mark-paid`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Invoice marked paid");
} else {
toast.error(result.error || "Failed to mark invoice paid");
}
},
viewExpenses: async (params, setData) => {
const queryParams = new URLSearchParams();
if (params?.status) queryParams.set("status", String(params.status));
const url = `/api/v1/expenses${queryParams.toString() ? `?${queryParams}` : ""}`;
const res = await fetch(url);
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
refreshExpenses: async (_params, setData) => {
const res = await fetch("/api/v1/expenses");
const expenses = await res.json();
setData((prev) => ({ ...prev, expenses }));
},
createExpense: async (params, setData) => {
if (
!params?.vendor ||
!params?.category ||
params?.amount === undefined
) {
toast.error("Vendor, category, and amount required");
return;
}
try {
const res = await fetch("/api/v1/expenses", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
const expense = await res.json();
if (res.ok) {
toast.success("Expense created");
const listRes = await fetch("/api/v1/expenses");
const expenses = await listRes.json();
setData((prev) => ({ ...prev, expenses }));
} else {
toast.error(expense.error || "Failed to create expense");
}
} catch (err) {
console.error("Failed to create expense:", err);
toast.error("Failed to create expense");
}
},
approveExpense: async (params) => {
if (!params?.expenseId) {
toast.error("Expense ID required");
return;
}
const res = await fetch(
`/api/v1/expenses/${params.expenseId}/approve`,
{ method: "POST" },
);
const result = await res.json();
if (res.ok) {
toast.success(result.message || "Expense approved");
} else {
toast.error(result.error || "Failed to approve expense");
}
},
},
},
);
// =============================================================================
// Chart Helpers
// =============================================================================
function isISODate(value: unknown): boolean {
if (typeof value !== "string") return false;
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value);
}
function formatDateLabel(value: string): string {
const date = new Date(value);
if (isNaN(date.getTime())) return value;
return date.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
}
function processChartData(
items: Array<Record<string, unknown>>,
xKey: string,
yKey: string,
aggregate: "sum" | "count" | "avg" | null | undefined,
): { items: Array<Record<string, unknown>>; valueKey: string } {
if (items.length === 0) {
return { items: [], valueKey: yKey };
}
const firstXValue = items[0]?.[xKey];
const isDateKey = isISODate(firstXValue);
if (!aggregate) {
const formatted = items.map((item) => {
const xValue = item[xKey];
return {
...item,
label:
isDateKey && typeof xValue === "string"
? formatDateLabel(xValue)
: String(xValue ?? ""),
};
});
return { items: formatted, valueKey: yKey };
}
const groups = new Map<string, Array<Record<string, unknown>>>();
for (const item of items) {
const xValue = item[xKey];
let groupKey: string;
if (isDateKey && typeof xValue === "string") {
groupKey = xValue.split("T")[0] ?? xValue;
} else {
groupKey = String(xValue ?? "unknown");
}
const group = groups.get(groupKey) ?? [];
group.push(item);
groups.set(groupKey, group);
}
const valueKey = aggregate === "count" ? "count" : yKey;
const aggregated: Array<Record<string, unknown>> = [];
const sortedKeys = Array.from(groups.keys()).sort();
for (const key of sortedKeys) {
const group = groups.get(key)!;
let value: number;
if (aggregate === "count") {
value = group.length;
} else if (aggregate === "sum") {
value = group.reduce((sum, item) => {
const v = item[yKey];
return sum + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
} else {
const sum = group.reduce((s, item) => {
const v = item[yKey];
return s + (typeof v === "number" ? v : parseFloat(String(v)) || 0);
}, 0);
value = group.length > 0 ? sum / group.length : 0;
}
let label: string;
if (isDateKey) {
const date = new Date(key);
label = date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
});
} else {
label = key;
}
aggregated.push({
label,
[valueKey]: value,
_groupKey: key,
});
}
return { items: aggregated, valueKey };
}
// =============================================================================
// Fallback Component
// =============================================================================
export function Fallback({ type }: { type: string }) {
return (
<div className="p-4 border border-dashed rounded-lg text-muted-foreground text-sm">
Unknown component: {type}
</div>
);
}
+16 -42
View File
@@ -3,15 +3,14 @@
import { useMemo, useRef, type ReactNode } from "react";
import {
Renderer,
type ComponentRegistry,
type ComponentRenderer,
type Spec,
DataProvider,
VisibilityProvider,
ActionProvider,
} from "@json-render/react";
import { components, Fallback } from "./catalog/components";
import { executeAction } from "./catalog/actions";
import { registry, Fallback, handlers as createHandlers } from "./registry";
// =============================================================================
// DashboardRenderer
@@ -29,39 +28,9 @@ interface DashboardRendererProps {
loading?: boolean;
}
// Build registry - uses refs to avoid recreating on data changes
function buildRegistry(
dataRef: React.RefObject<Record<string, unknown>>,
setDataRef: React.RefObject<SetData | undefined>,
loading?: boolean,
): ComponentRegistry {
const registry: ComponentRegistry = {};
for (const [name, componentFn] of Object.entries(components)) {
registry[name] = (renderProps: {
element: { props: Record<string, unknown> };
children?: ReactNode;
}) =>
componentFn({
props: renderProps.element.props as never,
children: renderProps.children,
onAction: (a) => {
const setData = setDataRef.current;
const data = dataRef.current;
if (setData) {
executeAction(a.name, a.params, setData, data);
}
},
loading,
});
}
return registry;
}
// Fallback component for unknown types
const fallbackRegistry = (renderProps: { element: { type: string } }) => (
<Fallback type={renderProps.element.type} />
const fallback: ComponentRenderer = ({ element }) => (
<Fallback type={element.type} />
);
export function DashboardRenderer({
@@ -71,16 +40,21 @@ export function DashboardRenderer({
onDataChange,
loading,
}: DashboardRendererProps): ReactNode {
// Use refs to keep registry stable while still accessing latest data/setData
// Use refs so action handlers always see the latest data/setData
const dataRef = useRef(data);
const setDataRef = useRef(setData);
dataRef.current = data;
setDataRef.current = setData;
// Memoize registry - only changes when loading changes
const registry = useMemo(
() => buildRegistry(dataRef, setDataRef, loading),
[loading],
// Create ActionProvider-compatible handlers using getters so they
// always read the latest data/setData from refs
const actionHandlers = useMemo(
() =>
createHandlers(
() => setDataRef.current,
() => dataRef.current,
),
[],
);
if (!spec) return null;
@@ -88,11 +62,11 @@ export function DashboardRenderer({
return (
<DataProvider initialData={data} onDataChange={onDataChange}>
<VisibilityProvider>
<ActionProvider>
<ActionProvider handlers={actionHandlers}>
<Renderer
spec={spec}
registry={registry}
fallback={fallbackRegistry}
fallback={fallback}
loading={loading}
/>
</ActionProvider>
File diff suppressed because one or more lines are too long
-6
View File
@@ -70,25 +70,19 @@ const spec: Spec = {
root: "container",
elements: {
container: {
key: "container",
type: "Stack",
props: { direction: "vertical", gap: "medium" },
children: ["heading", "metric"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Revenue", size: "large" },
children: [],
parentKey: "container",
},
metric: {
key: "metric",
type: "Metric",
props: { label: "Total", value: "$12,345", format: "currency" },
children: [],
parentKey: "container",
},
},
};
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "~~proprietary~~",
"type": "module",
"dependencies": {
"@json-render/core": "^0.4.0",
"@json-render/react": "^0.4.0",
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0"
},
@@ -84,14 +84,11 @@ function createCustomerDetailSpec(
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["header", "details", "sections"],
parentKey: null,
},
header: {
key: "header",
type: "Stack",
props: {
direction: "horizontal",
@@ -99,38 +96,28 @@ function createCustomerDetailSpec(
distribute: "space-between",
},
children: ["customerInfo", "actions"],
parentKey: "root",
},
customerInfo: {
key: "customerInfo",
type: "Stack",
props: { direction: "vertical", gap: "xsmall" },
children: ["customerName", "customerEmail"],
parentKey: "header",
},
customerName: {
key: "customerName",
type: "Heading",
props: { text: customer?.name ?? "Unknown Customer", size: "xlarge" },
children: [],
parentKey: "customerInfo",
},
customerEmail: {
key: "customerEmail",
type: "Text",
props: { content: customer?.email ?? "", color: "secondary" },
children: [],
parentKey: "customerInfo",
},
actions: {
key: "actions",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["editBtn", "portalBtn"],
parentKey: "header",
},
editBtn: {
key: "editBtn",
type: "Button",
props: {
label: "Edit",
@@ -139,10 +126,8 @@ function createCustomerDetailSpec(
type: "secondary",
},
children: [],
parentKey: "actions",
},
portalBtn: {
key: "portalBtn",
type: "Button",
props: {
label: "Billing Portal",
@@ -151,31 +136,23 @@ function createCustomerDetailSpec(
type: "primary",
},
children: [],
parentKey: "actions",
},
details: {
key: "details",
type: "PropertyList",
props: { orientation: "horizontal" },
children: ["detailStatus", "detailCreated", "detailBalance"],
parentKey: "root",
},
detailStatus: {
key: "detailStatus",
type: "PropertyListItem",
props: { label: "Status", value: customer?.status ?? "Unknown" },
children: [],
parentKey: "details",
},
detailCreated: {
key: "detailCreated",
type: "PropertyListItem",
props: { label: "Created", value: customer?.created ?? "Unknown" },
children: [],
parentKey: "details",
},
detailBalance: {
key: "detailBalance",
type: "PropertyListItem",
props: {
label: "Balance",
@@ -184,42 +161,32 @@ function createCustomerDetailSpec(
: "$0.00",
},
children: [],
parentKey: "details",
},
sections: {
key: "sections",
type: "Accordion",
props: {},
children: ["paymentsSection", "subscriptionsSection", "invoicesSection"],
parentKey: "root",
},
// Payments Section
paymentsSection: {
key: "paymentsSection",
type: "AccordionItem",
props: { title: `Payments (${payments?.total ?? 0})`, defaultOpen: true },
children: paymentsList.length > 0 ? ["paymentsList"] : ["noPayments"],
parentKey: "sections",
},
paymentsList: {
key: "paymentsList",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 5).map((_, i) => `payment${i}`),
parentKey: "paymentsSection",
},
noPayments: {
key: "noPayments",
type: "Text",
props: { content: "No payments found", color: "secondary" },
children: [],
parentKey: "paymentsSection",
},
// Subscriptions Section
subscriptionsSection: {
key: "subscriptionsSection",
type: "AccordionItem",
props: {
title: `Subscriptions (${subscriptions?.total ?? 0})`,
@@ -229,54 +196,42 @@ function createCustomerDetailSpec(
subscriptionsList.length > 0
? ["subscriptionsList"]
: ["noSubscriptions"],
parentKey: "sections",
},
subscriptionsList: {
key: "subscriptionsList",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: subscriptionsList.slice(0, 5).map((_, i) => `subscription${i}`),
parentKey: "subscriptionsSection",
},
noSubscriptions: {
key: "noSubscriptions",
type: "Text",
props: { content: "No subscriptions found", color: "secondary" },
children: [],
parentKey: "subscriptionsSection",
},
// Invoices Section
invoicesSection: {
key: "invoicesSection",
type: "AccordionItem",
props: {
title: `Invoices (${invoices?.total ?? 0})`,
defaultOpen: false,
},
children: invoicesList.length > 0 ? ["invoicesList"] : ["noInvoices"],
parentKey: "sections",
},
invoicesList: {
key: "invoicesList",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 5).map((_, i) => `invoice${i}`),
parentKey: "invoicesSection",
},
noInvoices: {
key: "noInvoices",
type: "Text",
props: { content: "No invoices found", color: "secondary" },
children: [],
parentKey: "invoicesSection",
},
};
// Add payment cards
paymentsList.slice(0, 5).forEach((p, i) => {
elements[`payment${i}`] = {
key: `payment${i}`,
type: "PaymentCard",
props: {
amount: 0,
@@ -286,14 +241,12 @@ function createCustomerDetailSpec(
paymentId: p.id,
},
children: [],
parentKey: "paymentsList",
};
});
// Add subscription cards
subscriptionsList.slice(0, 5).forEach((s, i) => {
elements[`subscription${i}`] = {
key: `subscription${i}`,
type: "SubscriptionCard",
props: {
planName: s.planName,
@@ -304,14 +257,12 @@ function createCustomerDetailSpec(
currentPeriodEnd: s.currentPeriodEnd,
},
children: [],
parentKey: "subscriptionsList",
};
});
// Add invoice cards
invoicesList.slice(0, 5).forEach((inv, i) => {
elements[`invoice${i}`] = {
key: `invoice${i}`,
type: "InvoiceCard",
props: {
invoiceNumber: inv.invoiceNumber,
@@ -321,7 +272,6 @@ function createCustomerDetailSpec(
dueDate: inv.dueDate,
},
children: [],
parentKey: "invoicesList",
};
});
@@ -36,28 +36,21 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "actions", "list", "pagination"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Customer Directory", size: "xlarge" },
children: [],
parentKey: "root",
},
stats: {
key: "stats",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["totalMetric"],
parentKey: "root",
},
totalMetric: {
key: "totalMetric",
type: "Metric",
props: {
label: "Total Customers",
@@ -65,17 +58,13 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "stats",
},
actions: {
key: "actions",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"],
parentKey: "root",
},
refreshBtn: {
key: "refreshBtn",
type: "Button",
props: {
label: "Refresh",
@@ -83,10 +72,8 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "actions",
},
exportBtn: {
key: "exportBtn",
type: "Button",
props: {
label: "Export CSV",
@@ -95,24 +82,18 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "actions",
},
list: {
key: "list",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: customerList.slice(0, 10).map((_, i) => `customer${i}`),
parentKey: "root",
},
pagination: {
key: "pagination",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: customers?.hasMore ? ["loadMore"] : [],
parentKey: "root",
},
loadMore: {
key: "loadMore",
type: "Button",
props: {
label: "Load More",
@@ -124,13 +105,11 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "pagination",
},
};
customerList.slice(0, 10).forEach((c, i) => {
elements[`customer${i}`] = {
key: `customer${i}`,
type: "CustomerCard",
props: {
name: c.name,
@@ -139,7 +118,6 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
customerId: c.id,
},
children: [],
parentKey: "list",
};
});
-82
View File
@@ -38,28 +38,21 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
root: "root",
elements: {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "refresh"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Revenue Dashboard", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"],
parentKey: "root",
},
m1: {
key: "m1",
type: "Metric",
props: {
label: "Payment Volume",
@@ -70,10 +63,8 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
m2: {
key: "m2",
type: "Metric",
props: {
label: "Active Subscriptions",
@@ -84,10 +75,8 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
m3: {
key: "m3",
type: "Metric",
props: {
label: "Total Customers",
@@ -95,10 +84,8 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
refresh: {
key: "refresh",
type: "Button",
props: {
label: "Refresh Data",
@@ -106,7 +93,6 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "root",
},
},
};
@@ -129,28 +115,21 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "payments", "refresh"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Recent Payments", size: "xlarge" },
children: [],
parentKey: "root",
},
stats: {
key: "stats",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["s1", "s2"],
parentKey: "root",
},
s1: {
key: "s1",
type: "Metric",
props: {
label: "Total Volume",
@@ -158,10 +137,8 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "stats",
},
s2: {
key: "s2",
type: "Metric",
props: {
label: "Success Rate",
@@ -169,17 +146,13 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "stats",
},
payments: {
key: "payments",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 5).map((_, i) => `p${i}`),
parentKey: "root",
},
refresh: {
key: "refresh",
type: "Button",
props: {
label: "Refresh Payments",
@@ -187,13 +160,11 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "root",
},
};
paymentsList.slice(0, 5).forEach((p, i) => {
elements[`p${i}`] = {
key: `p${i}`,
type: "PaymentCard",
props: {
amount: 0, // We'll use formattedAmount in description
@@ -203,7 +174,6 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
paymentId: p.id,
},
children: [],
parentKey: "payments",
};
});
@@ -229,28 +199,21 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "alert", "subs", "refresh"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Subscriptions Overview", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"],
parentKey: "root",
},
m1: {
key: "m1",
type: "Metric",
props: {
label: "Active",
@@ -258,10 +221,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
m2: {
key: "m2",
type: "Metric",
props: {
label: "Trialing",
@@ -269,10 +230,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
m3: {
key: "m3",
type: "Metric",
props: {
label: "Past Due",
@@ -280,27 +239,21 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: subscriptions?.pastDue ? "negative" : "neutral",
},
children: [],
parentKey: "metrics",
},
alert: {
key: "alert",
type: "Banner",
props: {
title: `${subscriptions?.trialing ?? 0} subscriptions currently trialing`,
type: "default",
},
children: [],
parentKey: "root",
},
subs: {
key: "subs",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: subsList.slice(0, 5).map((_, i) => `sub${i}`),
parentKey: "root",
},
refresh: {
key: "refresh",
type: "Button",
props: {
label: "Refresh Subscriptions",
@@ -308,13 +261,11 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "root",
},
};
subsList.slice(0, 5).forEach((s, i) => {
elements[`sub${i}`] = {
key: `sub${i}`,
type: "SubscriptionCard",
props: {
planName: s.planName,
@@ -323,7 +274,6 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
interval: (s.interval as "month" | "year") ?? "month",
},
children: [],
parentKey: "subs",
};
});
@@ -346,28 +296,21 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "customers", "refresh"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Customer Directory", size: "xlarge" },
children: [],
parentKey: "root",
},
stats: {
key: "stats",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["s1"],
parentKey: "root",
},
s1: {
key: "s1",
type: "Metric",
props: {
label: "Total Customers",
@@ -375,17 +318,13 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "stats",
},
customers: {
key: "customers",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: customersList.slice(0, 5).map((_, i) => `c${i}`),
parentKey: "root",
},
refresh: {
key: "refresh",
type: "Button",
props: {
label: "Refresh Customers",
@@ -393,13 +332,11 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "root",
},
};
customersList.slice(0, 5).forEach((c, i) => {
elements[`c${i}`] = {
key: `c${i}`,
type: "CustomerCard",
props: {
name: c.name,
@@ -408,7 +345,6 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
customerId: c.id,
},
children: [],
parentKey: "customers",
};
});
@@ -434,28 +370,21 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "invoices", "refresh"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Invoice Management", size: "xlarge" },
children: [],
parentKey: "root",
},
stats: {
key: "stats",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["s1", "s2", "s3"],
parentKey: "root",
},
s1: {
key: "s1",
type: "Metric",
props: {
label: "Outstanding",
@@ -463,10 +392,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "stats",
},
s2: {
key: "s2",
type: "Metric",
props: {
label: "Paid",
@@ -474,10 +401,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "stats",
},
s3: {
key: "s3",
type: "Metric",
props: {
label: "Overdue",
@@ -485,17 +410,13 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
},
children: [],
parentKey: "stats",
},
invoices: {
key: "invoices",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 5).map((_, i) => `inv${i}`),
parentKey: "root",
},
refresh: {
key: "refresh",
type: "Button",
props: {
label: "Refresh Invoices",
@@ -503,13 +424,11 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "root",
},
};
invoicesList.slice(0, 5).forEach((inv, i) => {
elements[`inv${i}`] = {
key: `inv${i}`,
type: "InvoiceCard",
props: {
invoiceNumber: inv.invoiceNumber,
@@ -518,7 +437,6 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
dueDate: inv.dueDate,
},
children: [],
parentKey: "invoices",
};
});
@@ -41,7 +41,6 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: [
@@ -52,24 +51,18 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
"list",
"pagination",
],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Invoices", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["outstandingMetric", "paidMetric", "overdueMetric"],
parentKey: "root",
},
outstandingMetric: {
key: "outstandingMetric",
type: "Metric",
props: {
label: "Outstanding",
@@ -77,10 +70,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
paidMetric: {
key: "paidMetric",
type: "Metric",
props: {
label: "Paid",
@@ -88,10 +79,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
overdueMetric: {
key: "overdueMetric",
type: "Metric",
props: {
label: "Overdue",
@@ -99,10 +88,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
},
children: [],
parentKey: "metrics",
},
alert: {
key: "alert",
type: "Banner",
props: {
title:
@@ -112,24 +99,18 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: invoices?.overdue !== "$0.00" ? "caution" : "default",
},
children: [],
parentKey: "root",
},
filters: {
key: "filters",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "createBtn", "exportBtn"],
parentKey: "root",
},
refreshBtn: {
key: "refreshBtn",
type: "Button",
props: { label: "Refresh", action: "refreshInvoices", type: "secondary" },
children: [],
parentKey: "filters",
},
createBtn: {
key: "createBtn",
type: "Button",
props: {
label: "Create Invoice",
@@ -138,10 +119,8 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "primary",
},
children: [],
parentKey: "filters",
},
exportBtn: {
key: "exportBtn",
type: "Button",
props: {
label: "Export CSV",
@@ -150,24 +129,18 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "filters",
},
list: {
key: "list",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`),
parentKey: "root",
},
pagination: {
key: "pagination",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: invoices?.hasMore ? ["loadMore"] : [],
parentKey: "root",
},
loadMore: {
key: "loadMore",
type: "Button",
props: {
label: "Load More",
@@ -176,13 +149,11 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "pagination",
},
};
invoicesList.slice(0, 10).forEach((inv, i) => {
elements[`invoice${i}`] = {
key: `invoice${i}`,
type: "InvoiceCard",
props: {
invoiceNumber: inv.invoiceNumber,
@@ -198,7 +169,6 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
customerEmail: inv.customerEmail,
},
children: [],
parentKey: "list",
};
});
@@ -56,14 +56,11 @@ function createPaymentDetailSpec(
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["header", "details", "actions", "refundsSection"],
parentKey: null,
},
header: {
key: "header",
type: "Stack",
props: {
direction: "horizontal",
@@ -71,31 +68,23 @@ function createPaymentDetailSpec(
distribute: "space-between",
},
children: ["paymentInfo", "statusBadge"],
parentKey: "root",
},
paymentInfo: {
key: "paymentInfo",
type: "Stack",
props: { direction: "vertical", gap: "xsmall" },
children: ["paymentAmount", "paymentId"],
parentKey: "header",
},
paymentAmount: {
key: "paymentAmount",
type: "Heading",
props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" },
children: [],
parentKey: "paymentInfo",
},
paymentId: {
key: "paymentId",
type: "Text",
props: { content: paymentId, color: "secondary", size: "small" },
children: [],
parentKey: "paymentInfo",
},
statusBadge: {
key: "statusBadge",
type: "Badge",
props: {
label: payment?.status ?? "unknown",
@@ -107,10 +96,8 @@ function createPaymentDetailSpec(
: "negative",
},
children: [],
parentKey: "header",
},
details: {
key: "details",
type: "PropertyList",
props: { orientation: "vertical" },
children: [
@@ -119,54 +106,42 @@ function createPaymentDetailSpec(
"detailCurrency",
"detailCustomer",
],
parentKey: "root",
},
detailDescription: {
key: "detailDescription",
type: "PropertyListItem",
props: {
label: "Description",
value: payment?.description ?? "No description",
},
children: [],
parentKey: "details",
},
detailCreated: {
key: "detailCreated",
type: "PropertyListItem",
props: { label: "Created", value: payment?.created ?? "Unknown" },
children: [],
parentKey: "details",
},
detailCurrency: {
key: "detailCurrency",
type: "PropertyListItem",
props: {
label: "Currency",
value: (payment?.currency ?? "usd").toUpperCase(),
},
children: [],
parentKey: "details",
},
detailCustomer: {
key: "detailCustomer",
type: "PropertyListItem",
props: { label: "Customer", value: payment?.customerId ?? "Guest" },
children: [],
parentKey: "details",
},
actions: {
key: "actions",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children:
payment?.status === "succeeded"
? ["refundBtn", "viewBtn"]
: ["viewBtn"],
parentKey: "root",
},
refundBtn: {
key: "refundBtn",
type: "Button",
props: {
label: "Refund Payment",
@@ -175,10 +150,8 @@ function createPaymentDetailSpec(
type: "destructive",
},
children: [],
parentKey: "actions",
},
viewBtn: {
key: "viewBtn",
type: "Button",
props: {
label: "View in Dashboard",
@@ -187,44 +160,34 @@ function createPaymentDetailSpec(
type: "secondary",
},
children: [],
parentKey: "actions",
},
refundsSection: {
key: "refundsSection",
type: "Accordion",
props: {},
children: ["refundsAccordion"],
parentKey: "root",
},
refundsAccordion: {
key: "refundsAccordion",
type: "AccordionItem",
props: {
title: `Refunds (${refunds?.total ?? 0})`,
defaultOpen: refundsList.length > 0,
},
children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"],
parentKey: "refundsSection",
},
refundsList: {
key: "refundsList",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: refundsList.slice(0, 5).map((_, i) => `refund${i}`),
parentKey: "refundsAccordion",
},
noRefunds: {
key: "noRefunds",
type: "Text",
props: { content: "No refunds", color: "secondary" },
children: [],
parentKey: "refundsAccordion",
},
};
refundsList.slice(0, 5).forEach((r, i) => {
elements[`refund${i}`] = {
key: `refund${i}`,
type: "RefundCard",
props: {
amount: r.amount,
@@ -233,7 +196,6 @@ function createPaymentDetailSpec(
reason: r.reason,
},
children: [],
parentKey: "refundsList",
};
});
@@ -38,28 +38,21 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "filters", "list", "pagination"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Payments", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["volumeMetric", "rateMetric", "countMetric"],
parentKey: "root",
},
volumeMetric: {
key: "volumeMetric",
type: "Metric",
props: {
label: "Total Volume",
@@ -67,10 +60,8 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
rateMetric: {
key: "rateMetric",
type: "Metric",
props: {
label: "Success Rate",
@@ -78,10 +69,8 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
countMetric: {
key: "countMetric",
type: "Metric",
props: {
label: "Total Payments",
@@ -89,24 +78,18 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
filters: {
key: "filters",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"],
parentKey: "root",
},
refreshBtn: {
key: "refreshBtn",
type: "Button",
props: { label: "Refresh", action: "refreshPayments", type: "secondary" },
children: [],
parentKey: "filters",
},
exportBtn: {
key: "exportBtn",
type: "Button",
props: {
label: "Export CSV",
@@ -115,24 +98,18 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "filters",
},
list: {
key: "list",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 10).map((_, i) => `payment${i}`),
parentKey: "root",
},
pagination: {
key: "pagination",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: payments?.hasMore ? ["loadMore"] : [],
parentKey: "root",
},
loadMore: {
key: "loadMore",
type: "Button",
props: {
label: "Load More",
@@ -144,13 +121,11 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "pagination",
},
};
paymentsList.slice(0, 10).forEach((p, i) => {
elements[`payment${i}`] = {
key: `payment${i}`,
type: "PaymentCard",
props: {
amount: 0,
@@ -160,7 +135,6 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
paymentId: p.id,
},
children: [],
parentKey: "list",
};
});
@@ -49,28 +49,21 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "filters", "list", "pagination"],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Products & Prices", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: ["productsMetric", "pricesMetric", "activeMetric"],
parentKey: "root",
},
productsMetric: {
key: "productsMetric",
type: "Metric",
props: {
label: "Total Products",
@@ -78,10 +71,8 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
pricesMetric: {
key: "pricesMetric",
type: "Metric",
props: {
label: "Total Prices",
@@ -89,10 +80,8 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
activeMetric: {
key: "activeMetric",
type: "Metric",
props: {
label: "Active Products",
@@ -100,24 +89,18 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
filters: {
key: "filters",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "createBtn"],
parentKey: "root",
},
refreshBtn: {
key: "refreshBtn",
type: "Button",
props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
children: [],
parentKey: "filters",
},
createBtn: {
key: "createBtn",
type: "Button",
props: {
label: "Create Product",
@@ -126,24 +109,18 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
type: "primary",
},
children: [],
parentKey: "filters",
},
list: {
key: "list",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: productsList.slice(0, 10).map((_, i) => `product${i}`),
parentKey: "root",
},
pagination: {
key: "pagination",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: products?.hasMore ? ["loadMore"] : [],
parentKey: "root",
},
loadMore: {
key: "loadMore",
type: "Button",
props: {
label: "Load More",
@@ -152,7 +129,6 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "pagination",
},
};
@@ -169,15 +145,12 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
: "No prices";
elements[`product${i}`] = {
key: `product${i}`,
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: [`productCard${i}`],
parentKey: "list",
};
elements[`productCard${i}`] = {
key: `productCard${i}`,
type: "PropertyList",
props: { orientation: "vertical" },
children: [
@@ -186,39 +159,30 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
`productPrice${i}`,
`productStatus${i}`,
],
parentKey: `product${i}`,
};
elements[`productName${i}`] = {
key: `productName${i}`,
type: "PropertyListItem",
props: { label: "Name", value: p.name },
children: [],
parentKey: `productCard${i}`,
};
elements[`productDesc${i}`] = {
key: `productDesc${i}`,
type: "PropertyListItem",
props: { label: "Description", value: p.description || "No description" },
children: [],
parentKey: `productCard${i}`,
};
elements[`productPrice${i}`] = {
key: `productPrice${i}`,
type: "PropertyListItem",
props: { label: "Prices", value: priceDisplay },
children: [],
parentKey: `productCard${i}`,
};
elements[`productStatus${i}`] = {
key: `productStatus${i}`,
type: "PropertyListItem",
props: { label: "Status", value: p.active ? "Active" : "Archived" },
children: [],
parentKey: `productCard${i}`,
};
});
@@ -42,7 +42,6 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = {
root: {
key: "root",
type: "Stack",
props: { direction: "vertical", gap: "large" },
children: [
@@ -53,17 +52,13 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
"list",
"pagination",
],
parentKey: null,
},
heading: {
key: "heading",
type: "Heading",
props: { text: "Subscriptions", size: "xlarge" },
children: [],
parentKey: "root",
},
metrics: {
key: "metrics",
type: "Stack",
props: { direction: "horizontal", gap: "medium" },
children: [
@@ -72,10 +67,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
"pastDueMetric",
"canceledMetric",
],
parentKey: "root",
},
activeMetric: {
key: "activeMetric",
type: "Metric",
props: {
label: "Active",
@@ -83,10 +76,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
trialingMetric: {
key: "trialingMetric",
type: "Metric",
props: {
label: "Trialing",
@@ -94,10 +85,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive",
},
children: [],
parentKey: "metrics",
},
pastDueMetric: {
key: "pastDueMetric",
type: "Metric",
props: {
label: "Past Due",
@@ -105,10 +94,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: subscriptions?.pastDue ? "negative" : "neutral",
},
children: [],
parentKey: "metrics",
},
canceledMetric: {
key: "canceledMetric",
type: "Metric",
props: {
label: "Canceled",
@@ -116,10 +103,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral",
},
children: [],
parentKey: "metrics",
},
alert: {
key: "alert",
type: "Banner",
props: {
title: subscriptions?.pastDue
@@ -128,17 +113,13 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: subscriptions?.pastDue ? "caution" : "default",
},
children: [],
parentKey: "root",
},
filters: {
key: "filters",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"],
parentKey: "root",
},
refreshBtn: {
key: "refreshBtn",
type: "Button",
props: {
label: "Refresh",
@@ -146,10 +127,8 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "filters",
},
exportBtn: {
key: "exportBtn",
type: "Button",
props: {
label: "Export CSV",
@@ -158,26 +137,20 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "filters",
},
list: {
key: "list",
type: "Stack",
props: { direction: "vertical", gap: "small" },
children: subscriptionsList
.slice(0, 10)
.map((_, i) => `subscription${i}`),
parentKey: "root",
},
pagination: {
key: "pagination",
type: "Stack",
props: { direction: "horizontal", gap: "small" },
children: subscriptions?.hasMore ? ["loadMore"] : [],
parentKey: "root",
},
loadMore: {
key: "loadMore",
type: "Button",
props: {
label: "Load More",
@@ -186,13 +159,11 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary",
},
children: [],
parentKey: "pagination",
},
};
subscriptionsList.slice(0, 10).forEach((s, i) => {
elements[`subscription${i}`] = {
key: `subscription${i}`,
type: "SubscriptionCard",
props: {
planName: s.planName,
@@ -208,7 +179,6 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
currentPeriodEnd: s.currentPeriodEnd,
},
children: [],
parentKey: "list",
};
});
+5 -1
View File
@@ -20,9 +20,13 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
"prepare": "husky",
"changeset": "changeset",
"ci:version": "changeset version && pnpm install --no-frozen-lockfile",
"ci:publish": "pnpm run build && changeset publish"
},
"devDependencies": {
"@changesets/cli": "2.29.8",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.1",
"husky": "^9.1.7",
+25
View File
@@ -0,0 +1,25 @@
# @json-render/codegen
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.4.0",
"version": "0.4.4",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+4 -13
View File
@@ -13,18 +13,15 @@ describe("traverseSpec", () => {
root: "root",
elements: {
root: {
key: "root",
type: "Card",
props: {},
children: ["child1", "child2"],
},
child1: {
key: "child1",
type: "Text",
props: {},
},
child2: {
key: "child2",
type: "Button",
props: {},
},
@@ -32,8 +29,8 @@ describe("traverseSpec", () => {
};
const visited: string[] = [];
traverseSpec(spec, (element) => {
visited.push(element.key);
traverseSpec(spec, (_element, key) => {
visited.push(key);
});
expect(visited).toEqual(["root", "child1", "child2"]);
@@ -41,8 +38,8 @@ describe("traverseSpec", () => {
it("handles empty spec", () => {
const visited: string[] = [];
traverseSpec(null as unknown as Spec, (element) => {
visited.push(element.key);
traverseSpec(null as unknown as Spec, (_element, key) => {
visited.push(key);
});
expect(visited).toEqual([]);
});
@@ -54,18 +51,15 @@ describe("collectUsedComponents", () => {
root: "root",
elements: {
root: {
key: "root",
type: "Card",
props: {},
children: ["child1", "child2"],
},
child1: {
key: "child1",
type: "Text",
props: {},
},
child2: {
key: "child2",
type: "Text",
props: {},
},
@@ -83,7 +77,6 @@ describe("collectDataPaths", () => {
root: "root",
elements: {
root: {
key: "root",
type: "Metric",
props: { valuePath: "analytics/revenue" },
},
@@ -99,7 +92,6 @@ describe("collectDataPaths", () => {
root: "root",
elements: {
root: {
key: "root",
type: "Text",
props: { content: { path: "user/name" } },
},
@@ -117,7 +109,6 @@ describe("collectActions", () => {
root: "root",
elements: {
root: {
key: "root",
type: "Button",
props: { action: "submit_form" },
},
+10 -5
View File
@@ -4,7 +4,12 @@ import type { Spec, UIElement } from "@json-render/core";
* Visitor function for spec traversal
*/
export interface TreeVisitor {
(element: UIElement, depth: number, parent: UIElement | null): void;
(
element: UIElement,
key: string,
depth: number,
parent: UIElement | null,
): void;
}
/**
@@ -25,7 +30,7 @@ export function traverseSpec(
const element = spec.elements[key];
if (!element) return;
visitor(element, depth, parent);
visitor(element, key, depth, parent);
if (element.children) {
for (const childKey of element.children) {
@@ -43,7 +48,7 @@ export function traverseSpec(
export function collectUsedComponents(spec: Spec): Set<string> {
const components = new Set<string>();
traverseSpec(spec, (element) => {
traverseSpec(spec, (element, _key) => {
components.add(element.type);
});
@@ -56,7 +61,7 @@ export function collectUsedComponents(spec: Spec): Set<string> {
export function collectDataPaths(spec: Spec): Set<string> {
const paths = new Set<string>();
traverseSpec(spec, (element) => {
traverseSpec(spec, (element, _key) => {
// Check props for data paths
for (const [propName, propValue] of Object.entries(element.props)) {
// Check for path props (e.g., valuePath, dataPath, bindPath)
@@ -141,7 +146,7 @@ function collectPathsFromCondition(
export function collectActions(spec: Spec): Set<string> {
const actions = new Set<string>();
traverseSpec(spec, (element) => {
traverseSpec(spec, (element, _key) => {
for (const propValue of Object.values(element.props)) {
// Check for action prop (string action name)
if (typeof propValue === "string" && propValue.startsWith("action:")) {
+19
View File
@@ -0,0 +1,19 @@
# @json-render/core
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
+8 -6
View File
@@ -116,14 +116,16 @@ while (streaming) {
const finalSpec = compiler.getResult();
```
SpecStream format (each line is a JSON patch):
SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
```jsonl
{"op":"set","path":"/root/type","value":"Card"}
{"op":"set","path":"/root/props","value":{"title":"Hello"}}
{"op":"set","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
{"op":"add","path":"/root/type","value":"Card"}
{"op":"add","path":"/root/props","value":{"title":"Hello"}}
{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
```
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
### Low-Level Utilities
```typescript
@@ -134,8 +136,8 @@ import {
} from "@json-render/core";
// Parse a single line
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// { op: "set", path: "/root", value: {} }
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
// { op: "add", path: "/root", value: {} }
// Apply a patch to an object
const obj = {};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.4.0",
"version": "0.4.4",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+2 -10
View File
@@ -72,12 +72,10 @@ describe("createCatalog", () => {
});
const validElement = {
key: "1",
type: "text",
props: { content: "Hello" },
};
const invalidElement = {
key: "1",
type: "text",
props: { content: 123 },
};
@@ -96,7 +94,7 @@ describe("createCatalog", () => {
const validSpec = {
root: "1",
elements: {
"1": { key: "1", type: "text", props: { content: "Hello" } },
"1": { type: "text", props: { content: "Hello" } },
},
};
@@ -120,16 +118,13 @@ describe("createCatalog", () => {
root: "card-1",
elements: {
"card-1": {
key: "card-1",
type: "card",
props: { title: "Hello" },
children: ["text-1"],
},
"text-1": {
key: "text-1",
type: "text",
props: { content: "World" },
parentKey: "card-1",
},
},
};
@@ -147,7 +142,7 @@ describe("createCatalog", () => {
const invalidSpec = {
root: "1",
elements: {
"1": { key: "1", type: "nonexistent", props: { content: "Hello" } },
"1": { type: "nonexistent", props: { content: "Hello" } },
},
};
@@ -398,7 +393,6 @@ describe("defineCatalog (new schema API)", () => {
root: s.string(),
elements: s.record(
s.object({
key: s.string(),
type: s.ref("catalog.components"),
props: s.any(),
children: s.array(s.string()),
@@ -489,7 +483,6 @@ describe("defineCatalog (new schema API)", () => {
root: "text-1",
elements: {
"text-1": {
key: "text-1",
type: "Text",
props: { content: "Hello" },
children: [],
@@ -558,7 +551,6 @@ describe("defineCatalog (new schema API)", () => {
root: "text-1",
elements: {
"text-1": {
key: "text-1",
type: "Text",
props: { content: "Hello" },
children: [],
+8 -10
View File
@@ -143,11 +143,9 @@ export function createCatalog<
const def = components[componentName]!;
return z.object({
key: z.string(),
type: z.literal(componentName as string),
props: def.props,
children: z.array(z.string()).optional(),
parentKey: z.string().nullable().optional(),
visible: VisibilityConditionSchema.optional(),
});
});
@@ -157,11 +155,9 @@ export function createCatalog<
if (componentSchemas.length === 0) {
elementSchema = z.object({
key: z.string(),
type: z.string(),
props: z.record(z.string(), z.unknown()),
children: z.array(z.string()).optional(),
parentKey: z.string().nullable().optional(),
visible: VisibilityConditionSchema.optional(),
}) as unknown as z.ZodType<UIElement>;
} else if (componentSchemas.length === 1) {
@@ -497,21 +493,23 @@ export function generateSystemPrompt<
}
// Output format
lines.push("OUTPUT FORMAT (JSONL):");
lines.push('{"op":"set","path":"/root","value":"element-key"}');
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
lines.push('{"op":"add","path":"/root","value":"element-key"}');
lines.push(
'{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
'{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
);
lines.push('{"op":"remove","path":"/elements/key"}');
lines.push("");
// Rules
lines.push("RULES:");
const baseRules = [
"First line sets /root to root element key",
"Add elements with /elements/{key}",
'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
"Children array contains string keys, not objects",
"Parent first, then children",
"Each element needs: key, type, props",
"Each element needs: type, props",
"ONLY use props listed above - never invent new props",
];
const allRules = [...baseRules, ...customRules];
+4 -1
View File
@@ -5,6 +5,7 @@ export type {
DynamicNumber,
DynamicBoolean,
UIElement,
FlatElement,
Spec,
VisibilityCondition,
LogicExpression,
@@ -27,8 +28,10 @@ export {
resolveDynamicValue,
getByPath,
setByPath,
addByPath,
removeByPath,
findFormValue,
// SpecStream - streaming format for building specs
// SpecStream - streaming format for building specs (RFC 6902)
parseSpecStreamLine,
applySpecStreamPatch,
compileSpecStream,
+8 -9
View File
@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(`{"op":"set","path":"/root","value":"card-1"}
{"op":"set","path":"/elements/card-1","value":{"key":"card-1","type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"],"parentKey":""}}
{"op":"set","path":"/elements/metric-1","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[],"parentKey":"card-1"}}
{"op":"set","path":"/elements/chart-1","value":{"key":"chart-1","type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[],"parentKey":"card-1"}}`);
lines.push(`{"op":"add","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
{"op":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
lines.push("");
// Components section
@@ -591,12 +591,11 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push("RULES:");
const baseRules = [
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}',
'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
"ONLY use components listed above",
"Each element value needs: key, type, props, children (array of child keys), parentKey",
"Use unique keys (e.g., 'header', 'metric-1', 'chart-revenue')",
"Root element's parentKey is empty string, children reference their parent's key",
"Each element value needs: type, props, children (array of child keys)",
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
];
const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => {
+435 -1
View File
@@ -1,5 +1,14 @@
import { describe, it, expect } from "vitest";
import { resolveDynamicValue, getByPath, setByPath } from "./types";
import {
resolveDynamicValue,
getByPath,
setByPath,
addByPath,
removeByPath,
applySpecStreamPatch,
compileSpecStream,
createSpecStreamCompiler,
} from "./types";
describe("getByPath", () => {
it("gets nested values with JSON pointer paths", () => {
@@ -123,3 +132,428 @@ describe("resolveDynamicValue", () => {
).toBeUndefined();
});
});
// =============================================================================
// JSON Pointer (RFC 6901) escaping
// =============================================================================
describe("JSON Pointer escaping (RFC 6901)", () => {
it("getByPath unescapes ~1 to /", () => {
const data = { "a/b": { c: 42 } };
expect(getByPath(data, "/a~1b/c")).toBe(42);
});
it("getByPath unescapes ~0 to ~", () => {
const data = { "a~b": 99 };
expect(getByPath(data, "/a~0b")).toBe(99);
});
it("getByPath handles combined ~0 and ~1 escaping", () => {
const data = { "a~/b": "found" };
expect(getByPath(data, "/a~0~1b")).toBe("found");
});
it("setByPath unescapes ~1 to / in keys", () => {
const data: Record<string, unknown> = {};
setByPath(data, "/a~1b", "value");
expect(data["a/b"]).toBe("value");
});
it("setByPath unescapes ~0 to ~ in keys", () => {
const data: Record<string, unknown> = {};
setByPath(data, "/a~0b", "value");
expect(data["a~b"]).toBe("value");
});
it("getByPath reads array elements properly", () => {
const data = { items: [10, 20, 30] };
expect(getByPath(data, "/items/0")).toBe(10);
expect(getByPath(data, "/items/2")).toBe(30);
});
});
// =============================================================================
// addByPath (RFC 6902 "add" semantics)
// =============================================================================
describe("addByPath", () => {
it("adds a property to an object", () => {
const data: Record<string, unknown> = { a: 1 };
addByPath(data, "/b", 2);
expect(data.b).toBe(2);
});
it("replaces an existing object property (add semantics)", () => {
const data: Record<string, unknown> = { a: 1 };
addByPath(data, "/a", 99);
expect(data.a).toBe(99);
});
it("inserts into an array at a specific index", () => {
const data: Record<string, unknown> = { arr: [1, 2, 3] };
addByPath(data, "/arr/1", 99);
expect(data.arr).toEqual([1, 99, 2, 3]);
});
it("appends to an array with - (end-of-array)", () => {
const data: Record<string, unknown> = { arr: [1, 2] };
addByPath(data, "/arr/-", 3);
expect(data.arr).toEqual([1, 2, 3]);
});
it("inserts at index 0 of an array", () => {
const data: Record<string, unknown> = { arr: ["b", "c"] };
addByPath(data, "/arr/0", "a");
expect(data.arr).toEqual(["a", "b", "c"]);
});
it("creates intermediate objects", () => {
const data: Record<string, unknown> = {};
addByPath(data, "/x/y/z", "deep");
expect(
((data.x as Record<string, unknown>).y as Record<string, unknown>).z,
).toBe("deep");
});
});
// =============================================================================
// removeByPath (RFC 6902 "remove" semantics)
// =============================================================================
describe("removeByPath", () => {
it("deletes an object property", () => {
const data: Record<string, unknown> = { a: 1, b: 2 };
removeByPath(data, "/a");
expect(data).toEqual({ b: 2 });
expect("a" in data).toBe(false);
});
it("removes an element from an array by index", () => {
const data: Record<string, unknown> = { arr: [1, 2, 3] };
removeByPath(data, "/arr/1");
expect(data.arr).toEqual([1, 3]);
});
it("removes nested properties", () => {
const data: Record<string, unknown> = { user: { name: "John", age: 30 } };
removeByPath(data, "/user/age");
expect(data.user).toEqual({ name: "John" });
});
it("is a no-op for non-existent paths", () => {
const data: Record<string, unknown> = { a: 1 };
removeByPath(data, "/b/c/d");
expect(data).toEqual({ a: 1 });
});
});
// =============================================================================
// applySpecStreamPatch - RFC 6902 operations
// =============================================================================
describe("applySpecStreamPatch", () => {
describe("add operation", () => {
it("adds a new object property", () => {
const obj: Record<string, unknown> = {};
applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" });
expect(obj.name).toBe("Alice");
});
it("adds nested properties creating intermediates", () => {
const obj: Record<string, unknown> = {};
applySpecStreamPatch(obj, {
op: "add",
path: "/user/name",
value: "Bob",
});
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
});
it("inserts into array at index", () => {
const obj: Record<string, unknown> = { items: [1, 3] };
applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 });
expect(obj.items).toEqual([1, 2, 3]);
});
it("appends to array with -", () => {
const obj: Record<string, unknown> = { items: [1, 2] };
applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 });
expect(obj.items).toEqual([1, 2, 3]);
});
});
describe("remove operation", () => {
it("removes an object property", () => {
const obj: Record<string, unknown> = { name: "Alice", age: 30 };
applySpecStreamPatch(obj, { op: "remove", path: "/age" });
expect(obj).toEqual({ name: "Alice" });
expect("age" in obj).toBe(false);
});
it("removes from array by index", () => {
const obj: Record<string, unknown> = { items: ["a", "b", "c"] };
applySpecStreamPatch(obj, { op: "remove", path: "/items/1" });
expect(obj.items).toEqual(["a", "c"]);
});
});
describe("replace operation", () => {
it("replaces an existing value", () => {
const obj: Record<string, unknown> = { name: "Alice" };
applySpecStreamPatch(obj, {
op: "replace",
path: "/name",
value: "Bob",
});
expect(obj.name).toBe("Bob");
});
it("replaces nested values", () => {
const obj: Record<string, unknown> = { user: { name: "Alice" } };
applySpecStreamPatch(obj, {
op: "replace",
path: "/user/name",
value: "Bob",
});
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
});
});
describe("move operation", () => {
it("moves a value from one path to another", () => {
const obj: Record<string, unknown> = { a: 1, b: 2 };
applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" });
expect(obj).toEqual({ b: 2, c: 1 });
expect("a" in obj).toBe(false);
});
it("moves nested values", () => {
const obj: Record<string, unknown> = {
source: { val: 42 },
target: {},
};
applySpecStreamPatch(obj, {
op: "move",
path: "/target/val",
from: "/source/val",
});
expect((obj.target as Record<string, unknown>).val).toBe(42);
expect("val" in (obj.source as Record<string, unknown>)).toBe(false);
});
it("is a no-op if from is missing", () => {
const obj: Record<string, unknown> = { a: 1 };
applySpecStreamPatch(obj, { op: "move", path: "/b" });
expect(obj).toEqual({ a: 1 });
});
});
describe("copy operation", () => {
it("copies a value from one path to another", () => {
const obj: Record<string, unknown> = { a: 1 };
applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" });
expect(obj).toEqual({ a: 1, b: 1 });
});
it("copies complex values", () => {
const obj: Record<string, unknown> = {
source: { nested: { value: 42 } },
};
applySpecStreamPatch(obj, {
op: "copy",
path: "/dest",
from: "/source/nested",
});
expect(obj.dest).toEqual({ value: 42 });
// Source should still exist
expect((obj.source as Record<string, unknown>).nested).toEqual({
value: 42,
});
});
it("is a no-op if from is missing", () => {
const obj: Record<string, unknown> = { a: 1 };
applySpecStreamPatch(obj, { op: "copy", path: "/b" });
expect(obj).toEqual({ a: 1 });
});
});
describe("test operation", () => {
it("succeeds when values match", () => {
const obj: Record<string, unknown> = { name: "Alice" };
expect(() =>
applySpecStreamPatch(obj, {
op: "test",
path: "/name",
value: "Alice",
}),
).not.toThrow();
});
it("succeeds for matching objects", () => {
const obj: Record<string, unknown> = { user: { name: "Alice", age: 30 } };
expect(() =>
applySpecStreamPatch(obj, {
op: "test",
path: "/user",
value: { name: "Alice", age: 30 },
}),
).not.toThrow();
});
it("succeeds for matching arrays", () => {
const obj: Record<string, unknown> = { items: [1, 2, 3] };
expect(() =>
applySpecStreamPatch(obj, {
op: "test",
path: "/items",
value: [1, 2, 3],
}),
).not.toThrow();
});
it("throws when values do not match", () => {
const obj: Record<string, unknown> = { name: "Alice" };
expect(() =>
applySpecStreamPatch(obj, {
op: "test",
path: "/name",
value: "Bob",
}),
).toThrow('Test operation failed: value at "/name" does not match');
});
it("throws when path does not exist", () => {
const obj: Record<string, unknown> = {};
expect(() =>
applySpecStreamPatch(obj, {
op: "test",
path: "/missing",
value: "anything",
}),
).toThrow();
});
});
});
// =============================================================================
// compileSpecStream
// =============================================================================
describe("compileSpecStream", () => {
it("compiles a series of add patches", () => {
const stream = `{"op":"add","path":"/name","value":"Alice"}
{"op":"add","path":"/age","value":30}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ name: "Alice", age: 30 });
});
it("handles remove operations", () => {
const stream = `{"op":"add","path":"/a","value":1}
{"op":"add","path":"/b","value":2}
{"op":"remove","path":"/a"}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ b: 2 });
expect("a" in result).toBe(false);
});
it("handles replace operations", () => {
const stream = `{"op":"add","path":"/name","value":"Alice"}
{"op":"replace","path":"/name","value":"Bob"}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ name: "Bob" });
});
it("handles move operations", () => {
const stream = `{"op":"add","path":"/old","value":"data"}
{"op":"move","path":"/new","from":"/old"}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ new: "data" });
});
it("handles copy operations", () => {
const stream = `{"op":"add","path":"/original","value":"data"}
{"op":"copy","path":"/duplicate","from":"/original"}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ original: "data", duplicate: "data" });
});
it("skips empty lines", () => {
const stream = `{"op":"add","path":"/a","value":1}
{"op":"add","path":"/b","value":2}`;
const result = compileSpecStream(stream);
expect(result).toEqual({ a: 1, b: 2 });
});
it("uses initial value", () => {
const stream = `{"op":"add","path":"/b","value":2}`;
const result = compileSpecStream(stream, { a: 1 });
expect(result).toEqual({ a: 1, b: 2 });
});
});
// =============================================================================
// createSpecStreamCompiler
// =============================================================================
describe("createSpecStreamCompiler", () => {
it("processes chunks incrementally", () => {
const compiler = createSpecStreamCompiler();
const { result, newPatches } = compiler.push(
'{"op":"add","path":"/name","value":"Alice"}\n',
);
expect(newPatches).toHaveLength(1);
expect(result).toEqual({ name: "Alice" });
});
it("handles partial lines across chunks", () => {
const compiler = createSpecStreamCompiler();
// First chunk is incomplete
const r1 = compiler.push('{"op":"add","path":"/na');
expect(r1.newPatches).toHaveLength(0);
// Complete the line
const r2 = compiler.push('me","value":"Alice"}\n');
expect(r2.newPatches).toHaveLength(1);
expect(r2.result).toEqual({ name: "Alice" });
});
it("processes remaining buffer on getResult", () => {
const compiler = createSpecStreamCompiler();
compiler.push('{"op":"add","path":"/name","value":"Alice"}');
// No newline, so not processed yet
const result = compiler.getResult();
expect(result).toEqual({ name: "Alice" });
});
it("resets to clean state", () => {
const compiler = createSpecStreamCompiler();
compiler.push('{"op":"add","path":"/name","value":"Alice"}\n');
compiler.reset();
const result = compiler.getResult();
expect(result).toEqual({});
});
it("tracks all applied patches", () => {
const compiler = createSpecStreamCompiler();
compiler.push('{"op":"add","path":"/a","value":1}\n');
compiler.push('{"op":"add","path":"/b","value":2}\n');
const patches = compiler.getPatches();
expect(patches).toHaveLength(2);
expect(patches[0]!.op).toBe("add");
expect(patches[1]!.op).toBe("add");
});
it("handles all RFC 6902 operations", () => {
const compiler = createSpecStreamCompiler();
compiler.push('{"op":"add","path":"/x","value":1}\n');
compiler.push('{"op":"add","path":"/y","value":2}\n');
compiler.push('{"op":"move","path":"/z","from":"/x"}\n');
compiler.push('{"op":"copy","path":"/w","from":"/y"}\n');
compiler.push('{"op":"remove","path":"/y"}\n');
const result = compiler.getResult();
expect(result).toEqual({ z: 1, w: 2 });
});
});
+207 -26
View File
@@ -53,20 +53,31 @@ export interface UIElement<
T extends string = string,
P = Record<string, unknown>,
> {
/** Unique key for reconciliation */
key: string;
/** Component type from the catalog */
type: T;
/** Component props */
props: P;
/** Child element keys (flat structure) */
children?: string[];
/** Parent element key (null for root) */
parentKey?: string | null;
/** Visibility condition */
visible?: VisibilityCondition;
}
/**
* Element with key and parentKey for use with flatToTree.
* When elements are in an array (not a keyed map), key and parentKey
* are needed to establish identity and parent-child relationships.
*/
export interface FlatElement<
T extends string = string,
P = Record<string, unknown>,
> extends UIElement<T, P> {
/** Unique key identifying this element */
key: string;
/** Parent element key (null for root) */
parentKey?: string | null;
}
/**
* Visibility condition types
*/
@@ -125,17 +136,20 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
export type ValidationMode = "strict" | "warn" | "ignore";
/**
* JSON patch operation types
* JSON patch operation types (RFC 6902)
*/
export type PatchOp = "add" | "remove" | "replace" | "set";
export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test";
/**
* JSON patch operation
* JSON patch operation (RFC 6902)
*/
export interface JsonPatch {
op: PatchOp;
path: string;
/** Required for add, replace, test */
value?: unknown;
/** Required for move, copy (source location) */
from?: string;
}
/**
@@ -157,16 +171,30 @@ export function resolveDynamicValue<T>(
}
/**
* Get a value from an object by JSON Pointer path
* Unescape a JSON Pointer token per RFC 6901 Section 4.
* ~1 is decoded to / and ~0 is decoded to ~ (order matters).
*/
function unescapeJsonPointer(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
/**
* Parse a JSON Pointer path into unescaped segments.
*/
function parseJsonPointer(path: string): string[] {
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
return raw.map(unescapeJsonPointer);
}
/**
* Get a value from an object by JSON Pointer path (RFC 6901)
*/
export function getByPath(obj: unknown, path: string): unknown {
if (!path || path === "/") {
return obj;
}
const segments = path.startsWith("/")
? path.slice(1).split("/")
: path.split("/");
const segments = parseJsonPointer(path);
let current: unknown = obj;
@@ -175,7 +203,10 @@ export function getByPath(obj: unknown, path: string): unknown {
return undefined;
}
if (typeof current === "object") {
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
current = current[index];
} else if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment];
} else {
return undefined;
@@ -193,7 +224,7 @@ function isNumericIndex(str: string): boolean {
}
/**
* Set a value in an object by JSON Pointer path.
* Set a value in an object by JSON Pointer path (RFC 6901).
* Automatically creates arrays when the path segment is a numeric index.
*/
export function setByPath(
@@ -201,9 +232,7 @@ export function setByPath(
path: string,
value: unknown,
): void {
const segments = path.startsWith("/")
? path.slice(1).split("/")
: path.split("/");
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
@@ -213,7 +242,8 @@ export function setByPath(
const segment = segments[i]!;
const nextSegment = segments[i + 1];
const nextIsNumeric =
nextSegment !== undefined && isNumericIndex(nextSegment);
nextSegment !== undefined &&
(isNumericIndex(nextSegment) || nextSegment === "-");
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
@@ -231,13 +261,131 @@ export function setByPath(
const lastSegment = segments[segments.length - 1]!;
if (Array.isArray(current)) {
const index = parseInt(lastSegment, 10);
current[index] = value;
if (lastSegment === "-") {
current.push(value);
} else {
const index = parseInt(lastSegment, 10);
current[index] = value;
}
} else {
current[lastSegment] = value;
}
}
/**
* Add a value per RFC 6902 "add" semantics.
* For objects: create-or-replace the member.
* For arrays: insert before the given index, or append if "-".
*/
export function addByPath(
obj: Record<string, unknown>,
path: string,
value: unknown,
): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i]!;
const nextSegment = segments[i + 1];
const nextIsNumeric =
nextSegment !== undefined &&
(isNumericIndex(nextSegment) || nextSegment === "-");
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
if (current[index] === undefined || typeof current[index] !== "object") {
current[index] = nextIsNumeric ? [] : {};
}
current = current[index] as Record<string, unknown> | unknown[];
} else {
if (!(segment in current) || typeof current[segment] !== "object") {
current[segment] = nextIsNumeric ? [] : {};
}
current = current[segment] as Record<string, unknown> | unknown[];
}
}
const lastSegment = segments[segments.length - 1]!;
if (Array.isArray(current)) {
if (lastSegment === "-") {
current.push(value);
} else {
const index = parseInt(lastSegment, 10);
current.splice(index, 0, value);
}
} else {
current[lastSegment] = value;
}
}
/**
* Remove a value per RFC 6902 "remove" semantics.
* For objects: delete the property.
* For arrays: splice out the element at the given index.
*/
export function removeByPath(obj: Record<string, unknown>, path: string): void {
const segments = parseJsonPointer(path);
if (segments.length === 0) return;
let current: Record<string, unknown> | unknown[] = obj;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i]!;
if (Array.isArray(current)) {
const index = parseInt(segment, 10);
if (current[index] === undefined || typeof current[index] !== "object") {
return; // path does not exist
}
current = current[index] as Record<string, unknown> | unknown[];
} else {
if (!(segment in current) || typeof current[segment] !== "object") {
return; // path does not exist
}
current = current[segment] as Record<string, unknown> | unknown[];
}
}
const lastSegment = segments[segments.length - 1]!;
if (Array.isArray(current)) {
const index = parseInt(lastSegment, 10);
if (index >= 0 && index < current.length) {
current.splice(index, 1);
}
} else {
delete current[lastSegment];
}
}
/**
* Deep equality check for RFC 6902 "test" operation.
*/
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== "object") return false;
if (Array.isArray(a)) {
if (!Array.isArray(b)) return false;
if (a.length !== b.length) return false;
return a.every((item, i) => deepEqual(item, b[i]));
}
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
}
/**
* Find a form value from params and/or data.
* Useful in action handlers to locate form input values regardless of path format.
@@ -334,17 +482,50 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
}
/**
* Apply a single SpecStream patch to an object.
* Apply a single RFC 6902 JSON Patch operation to an object.
* Mutates the object in place.
*
* Supports all six RFC 6902 operations: add, remove, replace, move, copy, test.
*
* @throws {Error} If a "test" operation fails (value mismatch).
*/
export function applySpecStreamPatch<T extends Record<string, unknown>>(
obj: T,
patch: SpecStreamLine,
): T {
if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
setByPath(obj, patch.path, patch.value);
} else if (patch.op === "remove") {
setByPath(obj, patch.path, undefined);
switch (patch.op) {
case "add":
addByPath(obj, patch.path, patch.value);
break;
case "replace":
// RFC 6902: target must exist. For streaming tolerance we set regardless.
setByPath(obj, patch.path, patch.value);
break;
case "remove":
removeByPath(obj, patch.path);
break;
case "move": {
if (!patch.from) break;
const moveValue = getByPath(obj, patch.from);
removeByPath(obj, patch.from);
addByPath(obj, patch.path, moveValue);
break;
}
case "copy": {
if (!patch.from) break;
const copyValue = getByPath(obj, patch.from);
addByPath(obj, patch.path, copyValue);
break;
}
case "test": {
const actual = getByPath(obj, patch.path);
if (!deepEqual(actual, patch.value)) {
throw new Error(
`Test operation failed: value at "${patch.path}" does not match`,
);
}
break;
}
}
return obj;
}
@@ -354,8 +535,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
* Each line should be a patch operation.
*
* @example
* const stream = `{"op":"set","path":"/name","value":"Alice"}
* {"op":"set","path":"/age","value":30}`;
* const stream = `{"op":"add","path":"/name","value":"Alice"}
* {"op":"add","path":"/age","value":30}`;
* const result = compileSpecStream(stream);
* // { name: "Alice", age: 30 }
*/
+25
View File
@@ -0,0 +1,25 @@
# @json-render/react
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+44 -55
View File
@@ -51,33 +51,36 @@ export const catalog = defineCatalog(schema, {
### 2. Define Component Implementations
```tsx
import { defineComponents, useData } from "@json-render/react";
import { defineRegistry, useData } from "@json-render/react";
import { catalog } from "./catalog";
export const components = defineComponents(catalog, {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.(props.action)}>
{props.label}
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<div className="card">
<h3>{props.title}</h3>
{props.description && <p>{props.description}</p>}
{children}
</div>
),
Button: ({ props, onAction }) => (
<button onClick={() => onAction?.({ name: props.action })}>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
</button>
),
Input: ({ props }) => {
const { get, set } = useData();
return (
<label>
{props.label}
<input
placeholder={props.placeholder ?? ""}
value={get("/form/value") ?? ""}
onChange={(e) => set("/form/value", e.target.value)}
/>
</label>
);
},
},
});
```
@@ -86,20 +89,15 @@ export const components = defineComponents(catalog, {
```tsx
import { Renderer, DataProvider, ActionProvider } from "@json-render/react";
import { registry } from "./registry";
function App({ spec }) {
const handleAction = (action: string) => {
console.log("Action triggered:", action);
};
return (
<DataProvider initialData={{ form: { value: "" } }}>
<ActionProvider onAction={handleAction}>
<Renderer
spec={spec}
catalog={catalog}
components={components}
/>
<ActionProvider handlers={{
submit: () => console.log("Submit"),
}}>
<Renderer spec={spec} registry={registry} />
</ActionProvider>
</DataProvider>
);
@@ -253,13 +251,14 @@ const { errors, validate } = useFieldValidation("/form/email", {
## Component Props
Components receive these props:
When using `defineRegistry`, components receive these props:
```typescript
interface ComponentProps<P> {
props: P; // Props from spec
interface ComponentContext<P> {
props: P; // Typed props from the catalog
children?: React.ReactNode; // Rendered children
onAction?: (action: string) => void;
onAction?: (action: { name: string; params?: Record<string, unknown> }) => void;
loading?: boolean; // Whether the parent is loading
}
```
@@ -274,13 +273,7 @@ const systemPrompt = catalog.prompt();
```tsx
import { defineCatalog } from "@json-render/core";
import {
schema,
defineComponents,
Renderer,
DataProvider,
ActionProvider,
} from "@json-render/react";
import { schema, defineRegistry, Renderer } from "@json-render/react";
import { z } from "zod";
const catalog = defineCatalog(schema, {
@@ -293,8 +286,10 @@ const catalog = defineCatalog(schema, {
actions: {},
});
const components = defineComponents(catalog, {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
const { registry } = defineRegistry(catalog, {
components: {
Greeting: ({ props }) => <h1>Hello, {props.name}!</h1>,
},
});
const spec = {
@@ -305,12 +300,6 @@ const spec = {
};
function App() {
return (
<DataProvider initialData={{}}>
<ActionProvider onAction={() => {}}>
<Renderer spec={spec} catalog={catalog} components={components} />
</ActionProvider>
</DataProvider>
);
return <Renderer spec={spec} registry={registry} />;
}
```
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.4.0",
"version": "0.4.4",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+105 -47
View File
@@ -1,8 +1,18 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import type { Spec, UIElement, JsonPatch } from "@json-render/core";
import { setByPath } from "@json-render/core";
import type {
Spec,
UIElement,
FlatElement,
JsonPatch,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
} from "@json-render/core";
/**
* Parse a single JSON patch line
@@ -20,56 +30,104 @@ function parsePatchLine(line: string): JsonPatch | null {
}
/**
* Apply a JSON patch to the current spec
* Set a value at a spec path (for add/replace operations).
*/
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
if (path === "/root") {
newSpec.root = value as string;
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
newSpec.elements[elementKey] = value as UIElement;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
propPath,
value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Remove a value at a spec path.
*/
function removeSpecValue(newSpec: Spec, path: string): void {
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
const { [elementKey]: _, ...rest } = newSpec.elements;
newSpec.elements = rest;
} else {
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
removeByPath(
newElement as unknown as Record<string, unknown>,
propPath,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
/**
* Get a value at a spec path.
*/
function getSpecValue(spec: Spec, path: string): unknown {
if (path === "/root") return spec.root;
return getByPath(spec as unknown as Record<string, unknown>, path);
}
/**
* Apply an RFC 6902 JSON patch to the current spec.
* Supports add, remove, replace, move, copy, and test operations.
*/
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = { ...spec, elements: { ...spec.elements } };
switch (patch.op) {
case "set":
case "add":
case "replace": {
// Handle root path
if (patch.path === "/root") {
newSpec.root = patch.value as string;
return newSpec;
}
// Handle elements paths
if (patch.path.startsWith("/elements/")) {
const pathParts = patch.path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return newSpec;
if (pathParts.length === 1) {
// Setting entire element
newSpec.elements[elementKey] = patch.value as UIElement;
} else {
// Setting property of element
const element = newSpec.elements[elementKey];
if (element) {
const propPath = "/" + pathParts.slice(1).join("/");
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
propPath,
patch.value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
setSpecValue(newSpec, patch.path, patch.value);
break;
}
case "remove": {
if (patch.path.startsWith("/elements/")) {
const elementKey = patch.path.slice("/elements/".length).split("/")[0];
if (elementKey) {
const { [elementKey]: _, ...rest } = newSpec.elements;
newSpec.elements = rest;
}
}
removeSpecValue(newSpec, patch.path);
break;
}
case "move": {
if (!patch.from) break;
const moveValue = getSpecValue(newSpec, patch.from);
removeSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, moveValue);
break;
}
case "copy": {
if (!patch.from) break;
const copyValue = getSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, copyValue);
break;
}
case "test": {
// test is a no-op for rendering purposes (validation only)
break;
}
}
@@ -236,18 +294,18 @@ export function useUIStream({
}
/**
* Convert a flat element list to a Spec
* Convert a flat element list to a Spec.
* Input elements use key/parentKey to establish identity and relationships.
* Output spec uses the map-based format where key is the map entry key
* and parent-child relationships are expressed through children arrays.
*/
export function flatToTree(
elements: Array<UIElement & { parentKey?: string | null }>,
): Spec {
export function flatToTree(elements: FlatElement[]): Spec {
const elementMap: Record<string, UIElement> = {};
let root = "";
// First pass: add all elements to map
for (const element of elements) {
elementMap[element.key] = {
key: element.key,
type: element.type,
props: element.props,
children: [],
+5 -2
View File
@@ -64,11 +64,14 @@ export type {
// Renderer
export {
// New API
// Registry
defineRegistry,
type DefineRegistryResult,
// createRenderer (higher-level, includes providers)
createRenderer,
type CreateRendererProps,
type ComponentMap,
// Legacy API
// Low-level
Renderer,
JSONUIProvider,
createRendererFromCatalog,
+154 -2
View File
@@ -5,11 +5,18 @@ import type {
UIElement,
Spec,
Action,
Catalog as NewCatalog,
Catalog,
SchemaDefinition,
LegacyCatalog,
ComponentDefinition,
} from "@json-render/core";
import type {
Components,
Actions,
ActionFn,
SetData,
DataModel,
} from "./catalog-types";
import { useIsVisible } from "./contexts/visibility";
import { useActions } from "./contexts/actions";
import { useData } from "./contexts/data";
@@ -232,6 +239,151 @@ export function createRendererFromCatalog<
};
}
// ============================================================================
// defineRegistry
// ============================================================================
/**
* Result returned by defineRegistry
*/
export interface DefineRegistryResult {
/** Component registry for `<Renderer registry={...} />` */
registry: ComponentRegistry;
/**
* Create ActionProvider-compatible handlers.
* Accepts getter functions so handlers always read the latest data/setData
* (e.g. from React refs).
*/
handlers: (
getSetData: () => SetData | undefined,
getData: () => DataModel,
) => Record<string, (params: Record<string, unknown>) => Promise<void>>;
/**
* Execute an action by name imperatively
* (for use outside the React tree, e.g. initial data loading).
*/
executeAction: (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data?: DataModel,
) => Promise<void>;
}
/**
* Create a registry from a catalog with components and/or actions.
*
* @example
* ```tsx
* // Components only
* const { registry } = defineRegistry(catalog, {
* components: {
* Card: ({ props, children }) => (
* <div className="card">{props.title}{children}</div>
* ),
* },
* });
*
* // Actions only
* const { handlers, executeAction } = defineRegistry(catalog, {
* actions: {
* viewCustomers: async (params, setData) => { ... },
* },
* });
*
* // Both
* const { registry, handlers, executeAction } = defineRegistry(catalog, {
* components: { ... },
* actions: { ... },
* });
* ```
*/
export function defineRegistry<C extends Catalog>(
_catalog: C,
options: {
components?: Components<C>;
actions?: Actions<C>;
},
): DefineRegistryResult {
// Build component registry
const registry: ComponentRegistry = {};
if (options.components) {
for (const [name, componentFn] of Object.entries(options.components)) {
registry[name] = ({
element,
children,
onAction,
loading,
}: ComponentRenderProps) => {
return (componentFn as DefineRegistryComponentFn)({
props: element.props,
children,
onAction,
loading,
});
};
}
}
// Build action helpers
const actionMap = options.actions
? (Object.entries(options.actions) as Array<
[string, DefineRegistryActionFn]
>)
: [];
const handlers = (
getSetData: () => SetData | undefined,
getData: () => DataModel,
): Record<string, (params: Record<string, unknown>) => Promise<void>> => {
const result: Record<
string,
(params: Record<string, unknown>) => Promise<void>
> = {};
for (const [name, actionFn] of actionMap) {
result[name] = async (params) => {
const setData = getSetData();
const data = getData();
if (setData) {
await actionFn(params, setData, data);
}
};
}
return result;
};
const executeAction = async (
actionName: string,
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel = {},
): Promise<void> => {
const entry = actionMap.find(([name]) => name === actionName);
if (entry) {
await entry[1](params, setData, data);
} else {
console.warn(`Unknown action: ${actionName}`);
}
};
return { registry, handlers, executeAction };
}
/** @internal */
type DefineRegistryComponentFn = (ctx: {
props: unknown;
children?: React.ReactNode;
onAction?: (action: Action) => void;
loading?: boolean;
}) => React.ReactNode;
/** @internal */
type DefineRegistryActionFn = (
params: Record<string, unknown> | undefined,
setData: SetData,
data: DataModel,
) => Promise<void>;
// ============================================================================
// NEW API
// ============================================================================
@@ -289,7 +441,7 @@ export function createRenderer<
TDef extends SchemaDefinition,
TCatalog extends { components: Record<string, { props: unknown }> },
>(
catalog: NewCatalog<TDef, TCatalog>,
catalog: Catalog<TDef, TCatalog>,
components: ComponentMap<TCatalog["components"]>,
): ComponentType<CreateRendererProps> {
// Convert component map to registry
-4
View File
@@ -15,16 +15,12 @@ export const schema = defineSchema((s) => ({
/** Flat map of elements by key */
elements: s.record(
s.object({
/** Unique key for this element */
key: s.string(),
/** Component type from catalog */
type: s.ref("catalog.components"),
/** Component props */
props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */
children: s.array(s.string()),
/** Parent element key (null for root) */
parentKey: s.string(),
/** Visibility condition */
visible: s.any(),
}),
+25
View File
@@ -0,0 +1,25 @@
# @json-render/remotion
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3
### Patch Changes
- 61ee8e5: include remove op in system prompt
- Updated dependencies [61ee8e5]
- @json-render/core@0.4.3
## 0.4.2
### Patch Changes
- 54bce09: add defineRegistry function
- Updated dependencies [54bce09]
- @json-render/core@0.4.2
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.4.1",
"version": "0.4.4",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+10 -9
View File
@@ -25,11 +25,12 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
{"op":"set","path":"/audio","value":{"tracks":[]}}`);
lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
{"op":"add","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
{"op":"add","path":"/clips","value":[]}
{"op":"add","path":"/clips/-","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
{"op":"add","path":"/clips/-","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
{"op":"add","path":"/audio","value":{"tracks":[]}}`);
lines.push("");
// Components
@@ -107,10 +108,10 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("RULES:");
const baseRules = [
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
"First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
"Then set /tracks array with video/overlay tracks",
"Then set each clip: /clips/0, /clips/1, etc.",
"Finally set /audio with {tracks:[]}",
'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}',
'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}',
'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}',
'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}',
"ONLY use components listed above",
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
+429 -19
View File
@@ -8,6 +8,9 @@ importers:
.:
devDependencies:
'@changesets/cli':
specifier: 2.29.8
version: 2.29.8(@types/node@22.19.6)
'@testing-library/dom':
specifier: ^10.4.1
version: 10.4.1
@@ -345,11 +348,11 @@ importers:
examples/stripe-app:
dependencies:
'@json-render/core':
specifier: ^0.4.0
version: 0.4.0(zod@4.3.5)
specifier: workspace:*
version: link:../../packages/core
'@json-render/react':
specifier: ^0.4.0
version: 0.4.0(react@18.3.1)(zod@4.3.5)
specifier: workspace:*
version: link:../../packages/react
'@stripe/ui-extension-sdk':
specifier: ^9.1.0
version: 9.1.0(stripe@13.11.0)
@@ -735,6 +738,61 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
'@changesets/apply-release-plan@7.0.14':
resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==}
'@changesets/assemble-release-plan@6.0.9':
resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==}
'@changesets/changelog-git@0.2.1':
resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==}
'@changesets/cli@2.29.8':
resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==}
hasBin: true
'@changesets/config@3.1.2':
resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==}
'@changesets/errors@0.2.0':
resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==}
'@changesets/get-dependents-graph@2.1.3':
resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==}
'@changesets/get-release-plan@4.0.14':
resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==}
'@changesets/get-version-range-type@0.4.0':
resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==}
'@changesets/git@3.0.4':
resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==}
'@changesets/logger@0.1.1':
resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==}
'@changesets/parse@0.4.2':
resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==}
'@changesets/pre@2.0.2':
resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==}
'@changesets/read@0.6.6':
resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==}
'@changesets/should-skip-package@0.1.2':
resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==}
'@changesets/types@4.1.0':
resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==}
'@changesets/types@6.1.0':
resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==}
'@changesets/write@0.4.0':
resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
@@ -1629,6 +1687,15 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/external-editor@1.0.3':
resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@istanbuljs/load-nyc-config@1.1.0':
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'}
@@ -1726,15 +1793,11 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@json-render/core@0.4.0':
resolution: {integrity: sha512-zcmNNetyXoqShG9qfXnipnkaGLn3XC7Kec/t3+C39mXAStg2RX29ciZwsZT+Fzo900LeRHRIGel7L/IHCdktrA==}
peerDependencies:
zod: ^4.0.0
'@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
'@json-render/react@0.4.0':
resolution: {integrity: sha512-OAXdWdOrAXHEFzpEF7xV+84D00JEmLMKlt5u0wc+C/P+q4q6TnpAWx2j28PBpDB2mpidkW9VnTUM+SCH9J8Lrw==}
peerDependencies:
react: ^19.0.0
'@manypkg/get-packages@1.1.3':
resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==}
'@next/env@16.1.1':
resolution: {integrity: sha512-3oxyM97Sr2PqiVyMyrZUtrtM3jqqFxOQJVuKclDsgj/L728iZt/GyslkN4NwarledZATCenbk4Offjk1hQmaAA==}
@@ -2990,6 +3053,9 @@ packages:
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/node@12.20.55':
resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==}
'@types/node@22.19.6':
resolution: {integrity: sha512-qm+G8HuG6hOHQigsi7VGuLjUVu6TtBo/F05zvX04Mw2uCg9Dv0Qxy3Qw7j41SidlTcl5D/5yg0SEZqOB+EqZnQ==}
@@ -3377,6 +3443,10 @@ packages:
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
@@ -3504,6 +3574,10 @@ packages:
resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==}
hasBin: true
better-path-resolve@1.0.0:
resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==}
engines: {node: '>=4'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -3599,6 +3673,9 @@ packages:
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
chardet@2.1.1:
resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
@@ -3831,6 +3908,10 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-indent@6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -4012,6 +4093,10 @@ packages:
resolution: {integrity: sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==}
engines: {node: '>=10.13.0'}
enquirer@2.4.1:
resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==}
engines: {node: '>=8.6'}
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
@@ -4240,6 +4325,9 @@ packages:
resolution: {integrity: sha512-eEh0xn8HlsuOBxFgIss+2mX85VAS4Qy3OSkjV7rlBWljtA4oWH37glVGyOZSZvErDT/yBywZdPGwCXuTvSG85g==}
engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0}
extendable-error@0.1.7:
resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==}
extract-zip@2.0.1:
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
engines: {node: '>= 10.17.0'}
@@ -4325,6 +4413,14 @@ packages:
resolution: {integrity: sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==}
engines: {node: '>= 6'}
fs-extra@7.0.1:
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
engines: {node: '>=6 <7 || >=8'}
fs-extra@8.1.0:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
fs-monkey@1.0.3:
resolution: {integrity: sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q==}
@@ -4501,6 +4597,10 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
human-id@4.1.3:
resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==}
hasBin: true
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@@ -4514,6 +4614,10 @@ packages:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
iconv-lite@0.7.2:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
icss-utils@5.1.0:
resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==}
engines: {node: ^10 || ^12 || >= 14}
@@ -4670,6 +4774,10 @@ packages:
resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
engines: {node: '>= 0.4'}
is-subdir@1.2.0:
resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==}
engines: {node: '>=4'}
is-symbol@1.1.1:
resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==}
engines: {node: '>= 0.4'}
@@ -4693,6 +4801,10 @@ packages:
resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==}
engines: {node: '>= 0.4'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
@@ -4965,6 +5077,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'}
@@ -5099,6 +5214,9 @@ packages:
lodash.sortby@4.7.0:
resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
lodash.startcase@4.4.0:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -5212,6 +5330,10 @@ packages:
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -5336,10 +5458,17 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
own-keys@1.0.1:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-filter@2.1.0:
resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==}
engines: {node: '>=8'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
@@ -5356,10 +5485,17 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-map@2.1.0:
resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==}
engines: {node: '>=6'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-manager-detector@0.2.11:
resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -5415,6 +5551,10 @@ packages:
engines: {node: '>=0.10'}
hasBin: true
pify@4.0.1:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
@@ -5495,6 +5635,11 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@2.8.8:
resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==}
engines: {node: '>=10.13.0'}
hasBin: true
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -5536,6 +5681,9 @@ packages:
resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==}
engines: {node: '>=0.6'}
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -5638,6 +5786,10 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
read-yaml-file@1.1.0:
resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==}
engines: {node: '>=6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -5892,6 +6044,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
spawndamnit@3.0.1:
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -5959,6 +6114,10 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
strip-bom@4.0.0:
resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
engines: {node: '>=8'}
@@ -6028,6 +6187,10 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
term-size@2.2.1:
resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==}
engines: {node: '>=8'}
terminal-link@2.1.1:
resolution: {integrity: sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==}
engines: {node: '>=8'}
@@ -6314,6 +6477,10 @@ packages:
unist-util-visit@5.0.0:
resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==}
universalify@0.1.2:
resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==}
engines: {node: '>= 4.0.0'}
universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
@@ -6902,6 +7069,150 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
'@changesets/apply-release-plan@7.0.14':
dependencies:
'@changesets/config': 3.1.2
'@changesets/get-version-range-type': 0.4.0
'@changesets/git': 3.0.4
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
detect-indent: 6.1.0
fs-extra: 7.0.1
lodash.startcase: 4.4.0
outdent: 0.5.0
prettier: 2.8.8
resolve-from: 5.0.0
semver: 7.7.3
'@changesets/assemble-release-plan@6.0.9':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
semver: 7.7.3
'@changesets/changelog-git@0.2.1':
dependencies:
'@changesets/types': 6.1.0
'@changesets/cli@2.29.8(@types/node@22.19.6)':
dependencies:
'@changesets/apply-release-plan': 7.0.14
'@changesets/assemble-release-plan': 6.0.9
'@changesets/changelog-git': 0.2.1
'@changesets/config': 3.1.2
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/get-release-plan': 4.0.14
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.6
'@changesets/should-skip-package': 0.1.2
'@changesets/types': 6.1.0
'@changesets/write': 0.4.0
'@inquirer/external-editor': 1.0.3(@types/node@22.19.6)
'@manypkg/get-packages': 1.1.3
ansi-colors: 4.1.3
ci-info: 3.9.0
enquirer: 2.4.1
fs-extra: 7.0.1
mri: 1.2.0
p-limit: 2.3.0
package-manager-detector: 0.2.11
picocolors: 1.1.1
resolve-from: 5.0.0
semver: 7.7.3
spawndamnit: 3.0.1
term-size: 2.2.1
transitivePeerDependencies:
- '@types/node'
'@changesets/config@3.1.2':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/get-dependents-graph': 2.1.3
'@changesets/logger': 0.1.1
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
micromatch: 4.0.8
'@changesets/errors@0.2.0':
dependencies:
extendable-error: 0.1.7
'@changesets/get-dependents-graph@2.1.3':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
picocolors: 1.1.1
semver: 7.7.3
'@changesets/get-release-plan@4.0.14':
dependencies:
'@changesets/assemble-release-plan': 6.0.9
'@changesets/config': 3.1.2
'@changesets/pre': 2.0.2
'@changesets/read': 0.6.6
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/get-version-range-type@0.4.0': {}
'@changesets/git@3.0.4':
dependencies:
'@changesets/errors': 0.2.0
'@manypkg/get-packages': 1.1.3
is-subdir: 1.2.0
micromatch: 4.0.8
spawndamnit: 3.0.1
'@changesets/logger@0.1.1':
dependencies:
picocolors: 1.1.1
'@changesets/parse@0.4.2':
dependencies:
'@changesets/types': 6.1.0
js-yaml: 4.1.1
'@changesets/pre@2.0.2':
dependencies:
'@changesets/errors': 0.2.0
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
fs-extra: 7.0.1
'@changesets/read@0.6.6':
dependencies:
'@changesets/git': 3.0.4
'@changesets/logger': 0.1.1
'@changesets/parse': 0.4.2
'@changesets/types': 6.1.0
fs-extra: 7.0.1
p-filter: 2.1.0
picocolors: 1.1.1
'@changesets/should-skip-package@0.1.2':
dependencies:
'@changesets/types': 6.1.0
'@manypkg/get-packages': 1.1.3
'@changesets/types@4.1.0': {}
'@changesets/types@6.1.0': {}
'@changesets/write@0.4.0':
dependencies:
'@changesets/types': 6.1.0
fs-extra: 7.0.1
human-id: 4.1.3
prettier: 2.8.8
'@csstools/color-helpers@5.1.0': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
@@ -7467,6 +7778,13 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/external-editor@1.0.3(@types/node@22.19.6)':
dependencies:
chardet: 2.1.1
iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 22.19.6
'@istanbuljs/load-nyc-config@1.1.0':
dependencies:
camelcase: 5.3.1
@@ -7674,16 +7992,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@json-render/core@0.4.0(zod@4.3.5)':
'@manypkg/find-root@1.1.0':
dependencies:
zod: 4.3.5
'@babel/runtime': 7.28.6
'@types/node': 12.20.55
find-up: 4.1.0
fs-extra: 8.1.0
'@json-render/react@0.4.0(react@18.3.1)(zod@4.3.5)':
'@manypkg/get-packages@1.1.3':
dependencies:
'@json-render/core': 0.4.0(zod@4.3.5)
react: 18.3.1
transitivePeerDependencies:
- zod
'@babel/runtime': 7.28.6
'@changesets/types': 4.1.0
'@manypkg/find-root': 1.1.0
fs-extra: 8.1.0
globby: 11.1.0
read-yaml-file: 1.1.0
'@next/env@16.1.1': {}
@@ -9003,6 +9326,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/node@12.20.55': {}
'@types/node@22.19.6':
dependencies:
undici-types: 6.21.0
@@ -9444,6 +9769,8 @@ snapshots:
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-colors@4.1.3: {}
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
@@ -9614,6 +9941,10 @@ snapshots:
baseline-browser-mapping@2.9.14: {}
better-path-resolve@1.0.0:
dependencies:
is-windows: 1.0.2
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -9702,6 +10033,8 @@ snapshots:
character-entities-legacy@3.0.0: {}
chardet@2.1.1: {}
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
@@ -9912,6 +10245,8 @@ snapshots:
dequal@2.0.3: {}
detect-indent@6.1.0: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
@@ -9995,6 +10330,11 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
enquirer@2.4.1:
dependencies:
ansi-colors: 4.1.3
strip-ansi: 6.0.1
entities@6.0.1: {}
environment@1.1.0: {}
@@ -10476,6 +10816,8 @@ snapshots:
jest-message-util: 28.1.3
jest-util: 28.1.3
extendable-error@0.1.7: {}
extract-zip@2.0.1:
dependencies:
debug: 4.4.3
@@ -10573,6 +10915,18 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
fs-extra@7.0.1:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-extra@8.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 4.0.0
universalify: 0.1.2
fs-monkey@1.0.3: {}
fs.realpath@1.0.0: {}
@@ -10769,6 +11123,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
human-id@4.1.3: {}
human-signals@2.1.0: {}
husky@9.1.7: {}
@@ -10777,6 +11133,10 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
icss-utils@5.1.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
@@ -10921,6 +11281,10 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-subdir@1.2.0:
dependencies:
better-path-resolve: 1.0.0
is-symbol@1.1.1:
dependencies:
call-bound: 1.0.4
@@ -10944,6 +11308,8 @@ snapshots:
call-bound: 1.0.4
get-intrinsic: 1.3.0
is-windows@1.0.2: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
@@ -11497,6 +11863,10 @@ snapshots:
json5@2.2.3: {}
jsonfile@4.0.0:
optionalDependencies:
graceful-fs: 4.2.11
jsx-ast-utils@3.3.5:
dependencies:
array-includes: 3.1.9
@@ -11613,6 +11983,8 @@ snapshots:
lodash.sortby@4.7.0: {}
lodash.startcase@4.4.0: {}
lodash@4.17.23: {}
log-update@6.1.0:
@@ -11733,6 +12105,8 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.2
mri@1.2.0: {}
ms@2.1.3: {}
mz@2.7.0:
@@ -11866,12 +12240,18 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
outdent@0.5.0: {}
own-keys@1.0.1:
dependencies:
get-intrinsic: 1.3.0
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-filter@2.1.0:
dependencies:
p-map: 2.1.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
@@ -11888,8 +12268,14 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-map@2.1.0: {}
p-try@2.2.0: {}
package-manager-detector@0.2.11:
dependencies:
quansync: 0.2.11
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -11929,6 +12315,8 @@ snapshots:
pidtree@0.6.0: {}
pify@4.0.1: {}
pirates@4.0.7: {}
pkg-dir@4.2.0:
@@ -11996,6 +12384,8 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@2.8.8: {}
prettier@3.7.4: {}
pretty-format@26.6.2:
@@ -12046,6 +12436,8 @@ snapshots:
dependencies:
side-channel: 1.1.0
quansync@0.2.11: {}
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}
@@ -12191,6 +12583,13 @@ snapshots:
react@19.2.3: {}
read-yaml-file@1.1.0:
dependencies:
graceful-fs: 4.2.11
js-yaml: 3.14.2
pify: 4.0.1
strip-bom: 3.0.0
readdirp@4.1.2: {}
recharts-scale@0.4.5:
@@ -12523,6 +12922,11 @@ snapshots:
space-separated-tokens@2.0.2: {}
spawndamnit@3.0.1:
dependencies:
cross-spawn: 7.0.6
signal-exit: 4.1.0
sprintf-js@1.0.3: {}
stack-utils@2.0.6:
@@ -12619,6 +13023,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
strip-bom@3.0.0: {}
strip-bom@4.0.0: {}
strip-final-newline@2.0.0: {}
@@ -12672,6 +13078,8 @@ snapshots:
tapable@2.3.0: {}
term-size@2.2.1: {}
terminal-link@2.1.1:
dependencies:
ansi-escapes: 4.3.2
@@ -12961,6 +13369,8 @@ snapshots:
unist-util-is: 6.0.1
unist-util-visit-parents: 6.0.2
universalify@0.1.2: {}
universalify@0.2.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.1):
+27 -19
View File
@@ -10,34 +10,27 @@ React renderer that converts JSON specs into React component trees.
## Quick Start
```typescript
import { Renderer } from "@json-render/react";
import { defineRegistry, Renderer } from "@json-render/react";
import { catalog } from "./catalog";
const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => <div>{props.title}{children}</div>,
},
});
function App({ spec }) {
return <Renderer spec={spec} catalog={catalog} />;
return <Renderer spec={spec} registry={registry} />;
}
```
## Creating a Catalog
```typescript
import { defineCatalog, defineComponents } from "@json-render/react";
import { schema } from "@json-render/react"; // Uses element tree schema
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry } from "@json-render/react";
import { z } from "zod";
// Define component implementations
const components = defineComponents(catalog, {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
});
// Create catalog with props schemas
export const catalog = defineCatalog(schema, {
components: {
@@ -54,6 +47,21 @@ export const catalog = defineCatalog(schema, {
},
},
});
// Define component implementations with type-safe props
const { registry } = defineRegistry(catalog, {
components: {
Button: ({ props }) => (
<button className={props.variant}>{props.label}</button>
),
Card: ({ props, children }) => (
<div className="card">
<h2>{props.title}</h2>
{children}
</div>
),
},
});
```
## Spec Structure (Element Tree)
@@ -85,8 +93,8 @@ The React schema uses an element tree format:
| Export | Purpose |
|--------|---------|
| `Renderer` | Render spec to React components |
| `defineRegistry` | Create a type-safe component registry from a catalog |
| `Renderer` | Render a spec using a registry |
| `schema` | Element tree schema |
| `defineComponents` | Type-safe component registry |
| `useData` | Access data context |
| `useActions` | Access actions context |