Compare commits

..
Author SHA1 Message Date
Chris Tate 7a2ce3e981 changeset 2026-02-06 14:04:53 -06:00
44 changed files with 776 additions and 1165 deletions
+1 -4
View File
@@ -14,8 +14,5 @@
"access": "public", "access": "public",
"baseBranch": "main", "baseBranch": "main",
"updateInternalDependencies": "patch", "updateInternalDependencies": "patch",
"privatePackages": { "ignore": []
"version": false,
"tag": false
}
} }
+2
View File
@@ -24,6 +24,8 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
+5 -19
View File
@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
</p> </p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core'; <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"add","path":"/root","value":{}} const jsonl = \`{"op":"set","path":"/root","value":{}}
{"op":"add","path":"/root/type","value":"Card"}\`; {"op":"set","path":"/root/type","value":"Card"}\`;
const spec = compileSpecStream<MySpec>(jsonl);`}</Code> const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
@@ -285,36 +285,22 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
} from '@json-render/core'; } from '@json-render/core';
// Parse a single line // Parse a single line
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}'); const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// Apply patch to object (mutates in place) // Apply patch to object (mutates in place)
const obj = {}; const obj = {};
applySpecStreamPatch(obj, patch);`}</Code> applySpecStreamPatch(obj, patch);`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3> <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 { <Code lang="typescript">{`interface SpecStreamLine {
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test'; op: 'set' | 'add' | 'replace' | 'remove';
path: string; path: string;
value?: unknown; // Required for add, replace, test value?: unknown;
from?: string; // Required for move, copy
} }
interface SpecStreamCompiler<T> { interface SpecStreamCompiler<T> {
push(chunk: string): { result: T; newPatches: SpecStreamLine[] }; push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
getResult(): T; getResult(): T;
getPatches(): SpecStreamLine[];
reset(): void; reset(): void;
}`}</Code> }`}</Code>
+6 -6
View File
@@ -94,15 +94,15 @@ type Registry = Record<string, React.ComponentType<ComponentRenderProps>>;`}</Co
<h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3> <h3 className="text-lg font-semibold mt-8 mb-4">useUIStream</h3>
<Code lang="typescript">{`const { <Code lang="typescript">{`const {
spec, // Spec | null - current UI state spec, // Spec - current UI state
isStreaming, // boolean - true while streaming isStreaming, // boolean - true while streaming
error, // Error | null error, // Error | null
send, // (prompt: string, context?: Record<string, unknown>) => Promise<void> send, // (prompt: string) => void
clear, // () => void - reset spec and error abort, // () => void
} = useUIStream({ } = useUIStream({
api: string, // API endpoint URL api: string, // API endpoint URL
onComplete?: (spec: Spec) => void, // Called when streaming completes onChunk?: (chunk: string) => void, // Called for each chunk
onError?: (error: Error) => void, // Called when an error occurs onFinish?: (spec: Spec) => void, // Called when streaming completes
});`}</Code> });`}</Code>
<h3 className="text-lg font-semibold mt-8 mb-4">useData</h3> <h3 className="text-lg font-semibold mt-8 mb-4">useData</h3>
+16
View File
@@ -275,9 +275,11 @@ export const openapiCatalog = createCatalog({
} }
interface SpecElement { interface SpecElement {
key: string;
type: string; type: string;
props: Record<string, unknown>; props: Record<string, unknown>;
children: string[]; children: string[];
parentKey: string;
} }
function schemaToSpec( function schemaToSpec(
@@ -293,6 +295,7 @@ function schemaToSpec(
if (schema.enum) { if (schema.enum) {
elements.set(key, { elements.set(key, {
key,
type: 'EnumField', type: 'EnumField',
props: { props: {
name, name,
@@ -303,9 +306,11 @@ function schemaToSpec(
defaultValue: schema.default as string, defaultValue: schema.default as string,
}, },
children: [], children: [],
parentKey,
}); });
} else if (schema.type === 'string') { } else if (schema.type === 'string') {
elements.set(key, { elements.set(key, {
key,
type: 'StringField', type: 'StringField',
props: { props: {
name, name,
@@ -318,9 +323,11 @@ function schemaToSpec(
defaultValue: schema.default as string, defaultValue: schema.default as string,
}, },
children: [], children: [],
parentKey,
}); });
} else if (schema.type === 'integer' || schema.type === 'number') { } else if (schema.type === 'integer' || schema.type === 'number') {
elements.set(key, { elements.set(key, {
key,
type: 'NumberField', type: 'NumberField',
props: { props: {
name, name,
@@ -333,9 +340,11 @@ function schemaToSpec(
defaultValue: schema.default as number, defaultValue: schema.default as number,
}, },
children: [], children: [],
parentKey,
}); });
} else if (schema.type === 'boolean') { } else if (schema.type === 'boolean') {
elements.set(key, { elements.set(key, {
key,
type: 'BooleanField', type: 'BooleanField',
props: { props: {
name, name,
@@ -344,6 +353,7 @@ function schemaToSpec(
defaultValue: schema.default as boolean, defaultValue: schema.default as boolean,
}, },
children: [], children: [],
parentKey,
}); });
} else if (schema.type === 'array' && schema.items) { } else if (schema.type === 'array' && schema.items) {
const childKeys: string[] = []; const childKeys: string[] = [];
@@ -351,6 +361,7 @@ function schemaToSpec(
childKeys.push(itemKey); childKeys.push(itemKey);
elements.set(key, { elements.set(key, {
key,
type: 'ArrayField', type: 'ArrayField',
props: { props: {
name, name,
@@ -358,6 +369,7 @@ function schemaToSpec(
description: schema.description, description: schema.description,
}, },
children: childKeys, children: childKeys,
parentKey,
}); });
} else if (schema.type === 'object' && schema.properties) { } else if (schema.type === 'object' && schema.properties) {
const childKeys: string[] = []; const childKeys: string[] = [];
@@ -374,6 +386,7 @@ function schemaToSpec(
} }
elements.set(key, { elements.set(key, {
key,
type: 'ObjectField', type: 'ObjectField',
props: { props: {
name, name,
@@ -381,6 +394,7 @@ function schemaToSpec(
description: schema.description, description: schema.description,
}, },
children: childKeys, children: childKeys,
parentKey,
}); });
} }
@@ -414,6 +428,7 @@ export function operationToSpec(
} }
elements.set(rootKey, { elements.set(rootKey, {
key: rootKey,
type: 'Form', type: 'Form',
props: { props: {
operationId, operationId,
@@ -423,6 +438,7 @@ export function operationToSpec(
description, description,
}, },
children: childKeys, children: childKeys,
parentKey: '',
}); });
return { return {
+112 -139
View File
@@ -14,86 +14,82 @@ export default function RegistryPage() {
life. life.
</p> </p>
{/* defineRegistry */} {/* Components Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">defineRegistry</h2> <h2 className="text-xl font-semibold mt-12 mb-4">Component Registry</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Use <code>defineRegistry</code> to create a type-safe registry from your Create a registry that maps catalog component types to React components:
catalog. Pass your components, actions, or both in a single call:
</p> </p>
<Code lang="tsx">{`import { defineRegistry } from '@json-render/react'; <Code lang="tsx">{`import { useAction } from '@json-render/react';
import { myCatalog } from './catalog';
export const { registry, handlers, executeAction } = defineRegistry(myCatalog, { const registry = {
components: { Card: ({ props, children }) => (
Card: ({ props, children }) => ( <div className="card">
<div className="card"> <h2>{props.title}</h2>
<h2>{props.title}</h2> {props.description && <p>{props.description}</p>}
{props.description && <p>{props.description}</p>} {children}
{children} </div>
</div> ),
),
Button: ({ props }) => {
Button: ({ props, onAction }) => ( const executeAction = useAction(props.action);
<button onClick={() => onAction?.({ name: props.action })}> return (
<button onClick={() => executeAction({})}>
{props.label} {props.label}
</button> </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> <h2 className="text-xl font-semibold mt-12 mb-4">Component Props</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Each component in the registry receives a <code>ComponentContext</code>{" "} Each component receives these props:
object:
</p> </p>
<Code lang="typescript">{`interface ComponentContext { <Code lang="typescript">{`interface ComponentProps<T = Record<string, unknown>> {
props: T; // Type-safe props from your catalog props: T; // Component props from the spec
children?: React.ReactNode; // Rendered children (for slot components) 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>
<p className="text-sm text-muted-foreground mt-4 mb-4"> // Type-safe props by extracting from your catalog
Props are automatically inferred from your catalog, so{" "} type CardProps = ComponentProps<{
<code>props.title</code> is typed as <code>string</code> if your catalog title: string;
defines it that way. 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> </p>
<Code lang="tsx">{`import { useDataValue, useDataBinding } from '@json-render/react';
{/* Action Handlers */} 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 */}
<h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2> <h2 className="text-xl font-semibold mt-12 mb-4">Action Handlers</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Instead of AI generating arbitrary code, it declares <em>intent</em> by Instead of AI generating arbitrary code, it declares <em>intent</em> by
@@ -121,6 +117,9 @@ const catalog = defineCatalog(schema, {
export_data: { export_data: {
params: z.object({ params: z.object({
format: z.enum(['csv', 'pdf', 'json']), format: z.enum(['csv', 'pdf', 'json']),
filters: z.object({
dateRange: z.string().nullable(),
}).nullable(),
}), }),
}, },
navigate: { navigate: {
@@ -131,104 +130,78 @@ const catalog = defineCatalog(schema, {
}, },
});`}</Code> });`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3"> <h3 className="text-lg font-medium mt-8 mb-3">ActionProvider</h3>
Implementing Action Handlers
</h3>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
Action handlers receive <code>(params, setData, data)</code> and are Provide action handlers to your app:
defined inside <code>defineRegistry</code>:
</p> </p>
<Code lang="tsx">{`export const { handlers, executeAction } = defineRegistry(catalog, { <Code lang="tsx">{`import { ActionProvider } from '@json-render/react';
actions: {
submit_form: async (params, setData) => { function App() {
const handlers = {
submit_form: async (params) => {
const response = await fetch('/api/submit', { const response = await fetch('/api/submit', {
method: 'POST', method: 'POST',
body: JSON.stringify({ formId: params.formId }), body: JSON.stringify({ formId: params.formId }),
}); });
const result = await response.json(); return response.json();
setData((prev) => ({ ...prev, formResult: result }));
}, },
export_data: async (params) => { export_data: async (params) => {
const blob = await generateExport(params.format); const blob = await generateExport(params.format, params.filters);
downloadBlob(blob, \`export.\${params.format}\`); downloadBlob(blob, \`export.\${params.format}\`);
}, },
navigate: (params) => { navigate: (params) => {
window.location.href = params.url; 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 ( return (
<div className="metric"> <ActionProvider handlers={handlers}>
<span className="label">{props.label}</span> {/* Your UI */}
<span className="value">{formatValue(value)}</span> </ActionProvider>
</div>
); );
}, }`}</Code>
TextField: ({ props }) => { <h3 className="text-lg font-medium mt-8 mb-3">
const { data, set } = useData(); Using Actions in Components
const value = getByPath(data, props.valuePath) as string; </h3>
<Code lang="tsx">{`import { useAction } from '@json-render/react';
// Using the useAction hook (recommended)
const Button = ({ props }) => {
const executeAction = useAction(props.action);
return ( return (
<input <button onClick={() => executeAction({})}>
value={value || ''} {props.label}
onChange={(e) => set(props.valuePath, e.target.value)} </button>
placeholder={props.placeholder}
/>
); );
},`}</Code> };
// Or for standalone use
function SubmitButton() {
const submitForm = useAction('submit_form');
return (
<button onClick={() => submitForm({ formId: 'contact' })}>
Submit
</button>
);
}`}</Code>
{/* Renderer Section */} {/* Renderer Section */}
<h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2> <h2 className="text-xl font-semibold mt-12 mb-4">Using the Renderer</h2>
<p className="text-sm text-muted-foreground mb-4"> <Code lang="tsx">{`import { Renderer, ActionProvider } from '@json-render/react';
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 ( return (
<DataProvider initialData={data}> <ActionProvider handlers={actionHandlers}>
<VisibilityProvider> <Renderer
<ActionProvider handlers={actionHandlers}> spec={uiSpec}
<Renderer spec={spec} registry={registry} /> registry={registry}
</ActionProvider> />
</VisibilityProvider> </ActionProvider>
</DataProvider>
); );
}`}</Code> }`}</Code>
+14 -4
View File
@@ -98,19 +98,25 @@ export default function SchemasPage() {
"root": "card-1", "root": "card-1",
"elements": { "elements": {
"card-1": { "card-1": {
"key": "card-1",
"type": "Card", "type": "Card",
"props": { "title": "Dashboard" }, "props": { "title": "Dashboard" },
"children": ["text-1", "button-1"] "children": ["text-1", "button-1"],
"parentKey": ""
}, },
"text-1": { "text-1": {
"key": "text-1",
"type": "Text", "type": "Text",
"props": { "content": "Welcome, $data.user.name" }, "props": { "content": "Welcome, $data.user.name" },
"children": [] "children": [],
"parentKey": "card-1"
}, },
"button-1": { "button-1": {
"key": "button-1",
"type": "Button", "type": "Button",
"props": { "label": "Click me" }, "props": { "label": "Click me" },
"children": [] "children": [],
"parentKey": "card-1"
} }
} }
}`}</Code> }`}</Code>
@@ -123,9 +129,11 @@ export default function SchemasPage() {
structure: structure:
</p> </p>
<Code lang="typescript">{`interface Element { <Code lang="typescript">{`interface Element {
key: string; // Unique identifier
type: string; // Component type from catalog type: string; // Component type from catalog
props: Record<string, any>; // Component properties props: Record<string, any>; // Component properties
children: string[]; // Array of child element keys children: string[]; // Array of child element keys
parentKey: string; // Parent element key (empty for root)
visible?: VisibilityRule; // Conditional display visible?: VisibilityRule; // Conditional display
}`}</Code> }`}</Code>
@@ -135,12 +143,14 @@ export default function SchemasPage() {
<code className="text-foreground">$data</code> prefix in props: <code className="text-foreground">$data</code> prefix in props:
</p> </p>
<Code lang="json">{`{ <Code lang="json">{`{
"key": "greeting",
"type": "Text", "type": "Text",
"props": { "props": {
"content": "$data.user.name", "content": "$data.user.name",
"count": "$data.items.length" "count": "$data.items.length"
}, },
"children": [] "children": [],
"parentKey": "card-1"
}`}</Code> }`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3> <h3 className="text-lg font-medium mt-8 mb-3">Action Format</h3>
+70 -20
View File
@@ -50,14 +50,18 @@ export default function SpecsPage() {
"root": "card-1", "root": "card-1",
"elements": { "elements": {
"card-1": { "card-1": {
"key": "card-1",
"type": "Card", "type": "Card",
"props": { "title": "Welcome" }, "props": { "title": "Welcome" },
"children": ["text-1"] "children": ["text-1"],
"parentKey": ""
}, },
"text-1": { "text-1": {
"key": "text-1",
"type": "Text", "type": "Text",
"props": { "content": "Hello, $data.user.name!" }, "props": { "content": "Hello, $data.user.name!" },
"children": [] "children": [],
"parentKey": "card-1"
} }
} }
}`}</Code> }`}</Code>
@@ -70,39 +74,53 @@ export default function SpecsPage() {
"root": "card-1", "root": "card-1",
"elements": { "elements": {
"card-1": { "card-1": {
"key": "card-1",
"type": "Card", "type": "Card",
"props": { "title": "User Profile", "padding": "md" }, "props": { "title": "User Profile", "padding": "md" },
"children": ["row-1", "button-1"] "children": ["row-1", "button-1"],
"parentKey": ""
}, },
"row-1": { "row-1": {
"key": "row-1",
"type": "Row", "type": "Row",
"props": { "gap": "md" }, "props": { "gap": "md" },
"children": ["avatar-1", "stack-1"] "children": ["avatar-1", "stack-1"],
"parentKey": "card-1"
}, },
"avatar-1": { "avatar-1": {
"key": "avatar-1",
"type": "Avatar", "type": "Avatar",
"props": { "src": "$data.user.avatar", "alt": "$data.user.name" }, "props": { "src": "$data.user.avatar", "alt": "$data.user.name" },
"children": [] "children": [],
"parentKey": "row-1"
}, },
"stack-1": { "stack-1": {
"key": "stack-1",
"type": "Stack", "type": "Stack",
"props": { "gap": "sm" }, "props": { "gap": "sm" },
"children": ["name-text", "email-text"] "children": ["name-text", "email-text"],
"parentKey": "row-1"
}, },
"name-text": { "name-text": {
"key": "name-text",
"type": "Text", "type": "Text",
"props": { "content": "$data.user.name", "variant": "heading" }, "props": { "content": "$data.user.name", "variant": "heading" },
"children": [] "children": [],
"parentKey": "stack-1"
}, },
"email-text": { "email-text": {
"key": "email-text",
"type": "Text", "type": "Text",
"props": { "content": "$data.user.email", "variant": "caption" }, "props": { "content": "$data.user.email", "variant": "caption" },
"children": [] "children": [],
"parentKey": "stack-1"
}, },
"button-1": { "button-1": {
"key": "button-1",
"type": "Button", "type": "Button",
"props": { "label": "Edit Profile" }, "props": { "label": "Edit Profile" },
"children": [] "children": [],
"parentKey": "card-1"
} }
} }
}`}</Code> }`}</Code>
@@ -115,16 +133,21 @@ export default function SpecsPage() {
"root": "page", "root": "page",
"elements": { "elements": {
"page": { "page": {
"key": "page",
"type": "Page", "type": "Page",
"props": {}, "props": {},
"children": ["header", "hero", "features", "footer"] "children": ["header", "hero", "features", "footer"],
"parentKey": ""
}, },
"header": { "header": {
"key": "header",
"type": "Header", "type": "Header",
"props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] }, "props": { "logo": "/logo.svg", "navItems": ["Products", "Pricing", "Docs"] },
"children": [] "children": [],
"parentKey": "page"
}, },
"hero": { "hero": {
"key": "hero",
"type": "Hero", "type": "Hero",
"props": { "props": {
"title": "Build UIs with JSON", "title": "Build UIs with JSON",
@@ -132,32 +155,43 @@ export default function SpecsPage() {
"ctaLabel": "Get Started", "ctaLabel": "Get Started",
"ctaHref": "/docs" "ctaHref": "/docs"
}, },
"children": [] "children": [],
"parentKey": "page"
}, },
"features": { "features": {
"key": "features",
"type": "Features", "type": "Features",
"props": { "columns": 3 }, "props": { "columns": 3 },
"children": ["feature-1", "feature-2", "feature-3"] "children": ["feature-1", "feature-2", "feature-3"],
"parentKey": "page"
}, },
"feature-1": { "feature-1": {
"key": "feature-1",
"type": "Feature", "type": "Feature",
"props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" }, "props": { "icon": "zap", "title": "Fast", "description": "Render UIs in milliseconds" },
"children": [] "children": [],
"parentKey": "features"
}, },
"feature-2": { "feature-2": {
"key": "feature-2",
"type": "Feature", "type": "Feature",
"props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" }, "props": { "icon": "shield", "title": "Secure", "description": "Validate all specs against your catalog" },
"children": [] "children": [],
"parentKey": "features"
}, },
"feature-3": { "feature-3": {
"key": "feature-3",
"type": "Feature", "type": "Feature",
"props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" }, "props": { "icon": "sparkles", "title": "AI-Ready", "description": "Generate prompts from your catalog" },
"children": [] "children": [],
"parentKey": "features"
}, },
"footer": { "footer": {
"key": "footer",
"type": "Footer", "type": "Footer",
"props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] }, "props": { "copyright": "2025 Acme Inc", "links": ["Privacy", "Terms", "Contact"] },
"children": [] "children": [],
"parentKey": "page"
} }
} }
}`}</Code> }`}</Code>
@@ -175,9 +209,11 @@ export default function SpecsPage() {
"root": "card-1", "root": "card-1",
"elements": { "elements": {
"card-1": { "card-1": {
"key": "card-1",
"type": "Card", "type": "Card",
"props": { "title": "My Card" }, "props": { "title": "My Card" },
"children": ["text-1"] "children": ["text-1"],
"parentKey": ""
}, },
"text-1": { ... } "text-1": { ... }
} }
@@ -188,11 +224,17 @@ export default function SpecsPage() {
Each element in the map has a consistent shape: Each element in the map has a consistent shape:
</p> </p>
<Code lang="json">{`{ <Code lang="json">{`{
"key": "unique-id",
"type": "ComponentName", "type": "ComponentName",
"props": { "label": "Hello" }, "props": { "label": "Hello" },
"children": ["child-1", "child-2"] "children": ["child-1", "child-2"],
"parentKey": "parent-id"
}`}</Code> }`}</Code>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1 mt-3 mb-4"> <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> <li>
<code className="text-foreground">type</code> — Component type from <code className="text-foreground">type</code> — Component type from
your catalog your catalog
@@ -204,6 +246,10 @@ export default function SpecsPage() {
<code className="text-foreground">children</code> — Array of child <code className="text-foreground">children</code> — Array of child
element keys element keys
</li> </li>
<li>
<code className="text-foreground">parentKey</code> — Key of parent
element (empty string for root)
</li>
</ul> </ul>
<h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3> <h3 className="text-lg font-medium mt-8 mb-3">Dynamic Data</h3>
@@ -212,13 +258,15 @@ export default function SpecsPage() {
<code className="text-foreground">$data</code> paths: <code className="text-foreground">$data</code> paths:
</p> </p>
<Code lang="json">{`{ <Code lang="json">{`{
"key": "metric-1",
"type": "Metric", "type": "Metric",
"props": { "props": {
"label": "Total Revenue", "label": "Total Revenue",
"value": "$data.metrics.revenue", "value": "$data.metrics.revenue",
"change": "$data.metrics.revenueChange" "change": "$data.metrics.revenueChange"
}, },
"children": [] "children": [],
"parentKey": "dashboard"
}`}</Code> }`}</Code>
<h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3> <h3 className="text-lg font-medium mt-8 mb-3">Conditional Visibility</h3>
@@ -227,11 +275,13 @@ export default function SpecsPage() {
<code className="text-foreground">visible</code> property: <code className="text-foreground">visible</code> property:
</p> </p>
<Code lang="json">{`{ <Code lang="json">{`{
"key": "alert-1",
"type": "Alert", "type": "Alert",
"props": { "props": {
"message": "You have unsaved changes" "message": "You have unsaved changes"
}, },
"children": [], "children": [],
"parentKey": "form",
"visible": { "visible": {
"path": "$data.form.isDirty", "path": "$data.form.isDirty",
"operator": "eq", "operator": "eq",
+22 -48
View File
@@ -18,10 +18,9 @@ export default function StreamingPage() {
format where each line is a JSON patch operation that progressively format where each line is a JSON patch operation that progressively
builds your spec: builds your spec:
</p> </p>
<Code lang="json">{`{"op":"add","path":"/root","value":"root"} <Code lang="json">{`{"op":"set","path":"/root","value":{"key":"root","type":"Card","props":{"title":"Dashboard"}}}
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}} {"op":"set","path":"/root/children/0","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue"}}}
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}} {"op":"set","path":"/root/children/1","value":{"key":"metric-2","type":"Metric","props":{"label":"Users"}}}`}</Code>
{"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> <h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
@@ -35,55 +34,34 @@ function App() {
isStreaming, // True while streaming isStreaming, // True while streaming
error, // Any error that occurred error, // Any error that occurred
send, // Function to start generation send, // Function to start generation
clear, // Function to reset spec and error abort, // Function to cancel streaming
} = useUIStream({ } = useUIStream({
api: '/api/generate', api: '/api/generate',
onComplete: (spec) => {}, // Optional: called when streaming completes onChunk: (chunk) => {}, // Optional: called for each chunk
onError: (error) => {}, // Optional: called when an error occurs onFinish: (spec) => {}, // Optional: called when complete
}); });
}`}</Code> }`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4"> <h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
Patch Operations (RFC 6902)
</h2>
<p className="text-sm text-muted-foreground mb-4"> <p className="text-sm text-muted-foreground mb-4">
SpecStream uses{" "} Supported operations:
<a
href="https://datatracker.ietf.org/doc/html/rfc6902"
className="underline"
target="_blank"
rel="noopener noreferrer"
>
RFC 6902 JSON Patch
</a>{" "}
operations:
</p> </p>
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4"> <ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
<li> <li>
<code className="text-foreground">add</code> — Add a value at a path <code className="text-foreground">set</code> — Set the value at a path
(creates or replaces for objects, inserts for arrays) (creates if needed)
</li> </li>
<li> <li>
<code className="text-foreground">remove</code> — Remove the value at <code className="text-foreground">add</code> — Add to an array at a
a path path
</li> </li>
<li> <li>
<code className="text-foreground">replace</code> — Replace an existing <code className="text-foreground">replace</code> — Replace value at a
value at a path path
</li> </li>
<li> <li>
<code className="text-foreground">move</code> — Move a value from one <code className="text-foreground">remove</code> — Remove value at a
path to another (requires{" "} path
<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> </li>
</ul> </ul>
@@ -134,14 +112,8 @@ export async function POST(req: Request) {
}`}</Code> }`}</Code>
<h2 className="text-xl font-semibold mt-12 mb-4">Aborting Streams</h2> <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() { <Code lang="tsx">{`function App() {
const { isStreaming, send, clear } = useUIStream({ const { isStreaming, send, abort } = useUIStream({
api: '/api/generate', api: '/api/generate',
}); });
@@ -150,7 +122,9 @@ export async function POST(req: Request) {
<button onClick={() => send('Create dashboard')}> <button onClick={() => send('Create dashboard')}>
Generate Generate
</button> </button>
<button onClick={clear}>Reset</button> {isStreaming && (
<button onClick={abort}>Cancel</button>
)}
</div> </div>
); );
}`}</Code> }`}</Code>
@@ -191,8 +165,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
</p> </p>
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core'; <Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}} const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`; {"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
const spec = compileSpecStream<MySpec>(jsonl); const spec = compileSpecStream<MySpec>(jsonl);
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code> // { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
+13 -11
View File
@@ -127,16 +127,15 @@ export const catalog = createCatalog({
Constrained output that your components render natively. Constrained output that your components render natively.
</p> </p>
<Code lang="json">{`{ <Code lang="json">{`{
"root": "dashboard", "key": "dashboard",
"elements": { "type": "Card",
"dashboard": { "props": {
"type": "Card", "title": "Revenue Dashboard",
"props": { "description": null
"title": "Revenue Dashboard" },
}, "children": [
"children": ["revenue"] {
}, "key": "revenue",
"revenue": {
"type": "Metric", "type": "Metric",
"props": { "props": {
"label": "Total Revenue", "label": "Total Revenue",
@@ -144,7 +143,7 @@ export const catalog = createCatalog({
"format": "currency" "format": "currency"
} }
} }
} ]
}`}</Code> }`}</Code>
</div> </div>
</div> </div>
@@ -171,11 +170,13 @@ export const catalog = createCatalog({
"root": "card", "root": "card",
"elements": { "elements": {
"card": { "card": {
"key": "card",
"type": "Card", "type": "Card",
"props": { "title": "Revenue" }, "props": { "title": "Revenue" },
"children": ["metric", "chart"] "children": ["metric", "chart"]
}, },
"metric": { "metric": {
"key": "metric",
"type": "Metric", "type": "Metric",
"props": { "props": {
"label": "Total Revenue", "label": "Total Revenue",
@@ -184,6 +185,7 @@ export const catalog = createCatalog({
} }
}, },
"chart": { "chart": {
"key": "chart",
"type": "Chart", "type": "Chart",
"props": { "props": {
"dataPath": "analytics/salesByRegion" "dataPath": "analytics/salesByRegion"
+2 -3
View File
@@ -62,9 +62,8 @@ USER REQUEST: ${sanitizedPrompt}
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change: 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 add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}} - To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
- To remove an element: {"op":"remove","path":"/elements/old-key"} - To update the root: {"op":"set","path":"/root","value":"new-root-key"}
- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array - 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.`; DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
+20 -5
View File
@@ -29,109 +29,124 @@ const SIMULATION_STAGES: SimulationStage[] = [
root: "card", root: "card",
elements: { elements: {
card: { card: {
key: "card",
type: "Card", type: "Card",
props: { title: "Contact Us", maxWidth: "md" }, props: { title: "Contact Us", maxWidth: "md" },
children: [], children: [],
}, },
}, },
}, },
stream: '{"op":"add","path":"/root","value":"card"}', stream: '{"op":"set","path":"/root","value":"card"}',
}, },
{ {
tree: { tree: {
root: "card", root: "card",
elements: { elements: {
card: { card: {
key: "card",
type: "Card", type: "Card",
props: { title: "Contact Us", maxWidth: "md" }, props: { title: "Contact Us", maxWidth: "md" },
children: ["name"], children: ["name"],
}, },
name: { name: {
key: "name",
type: "Input", type: "Input",
props: { label: "Name", name: "name" }, props: { label: "Name", name: "name" },
}, },
}, },
}, },
stream: stream:
'{"op":"add","path":"/elements/card","value":{"type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}', '{"op":"add","path":"/elements/card","value":{"key":"card","type":"Card","props":{"title":"Contact Us","maxWidth":"md"},"children":["name"]}}',
}, },
{ {
tree: { tree: {
root: "card", root: "card",
elements: { elements: {
card: { card: {
key: "card",
type: "Card", type: "Card",
props: { title: "Contact Us", maxWidth: "md" }, props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email"], children: ["name", "email"],
}, },
name: { name: {
key: "name",
type: "Input", type: "Input",
props: { label: "Name", name: "name" }, props: { label: "Name", name: "name" },
}, },
email: { email: {
key: "email",
type: "Input", type: "Input",
props: { label: "Email", name: "email" }, props: { label: "Email", name: "email" },
}, },
}, },
}, },
stream: stream:
'{"op":"add","path":"/elements/email","value":{"type":"Input","props":{"label":"Email","name":"email"}}}', '{"op":"add","path":"/elements/email","value":{"key":"email","type":"Input","props":{"label":"Email","name":"email"}}}',
}, },
{ {
tree: { tree: {
root: "card", root: "card",
elements: { elements: {
card: { card: {
key: "card",
type: "Card", type: "Card",
props: { title: "Contact Us", maxWidth: "md" }, props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message"], children: ["name", "email", "message"],
}, },
name: { name: {
key: "name",
type: "Input", type: "Input",
props: { label: "Name", name: "name" }, props: { label: "Name", name: "name" },
}, },
email: { email: {
key: "email",
type: "Input", type: "Input",
props: { label: "Email", name: "email" }, props: { label: "Email", name: "email" },
}, },
message: { message: {
key: "message",
type: "Textarea", type: "Textarea",
props: { label: "Message", name: "message" }, props: { label: "Message", name: "message" },
}, },
}, },
}, },
stream: stream:
'{"op":"add","path":"/elements/message","value":{"type":"Textarea","props":{"label":"Message","name":"message"}}}', '{"op":"add","path":"/elements/message","value":{"key":"message","type":"Textarea","props":{"label":"Message","name":"message"}}}',
}, },
{ {
tree: { tree: {
root: "card", root: "card",
elements: { elements: {
card: { card: {
key: "card",
type: "Card", type: "Card",
props: { title: "Contact Us", maxWidth: "md" }, props: { title: "Contact Us", maxWidth: "md" },
children: ["name", "email", "message", "submit"], children: ["name", "email", "message", "submit"],
}, },
name: { name: {
key: "name",
type: "Input", type: "Input",
props: { label: "Name", name: "name" }, props: { label: "Name", name: "name" },
}, },
email: { email: {
key: "email",
type: "Input", type: "Input",
props: { label: "Email", name: "email" }, props: { label: "Email", name: "email" },
}, },
message: { message: {
key: "message",
type: "Textarea", type: "Textarea",
props: { label: "Message", name: "message" }, props: { label: "Message", name: "message" },
}, },
submit: { submit: {
key: "submit",
type: "Button", type: "Button",
props: { label: "Send Message", variant: "primary" }, props: { label: "Send Message", variant: "primary" },
}, },
}, },
}, },
stream: stream:
'{"op":"add","path":"/elements/submit","value":{"type":"Button","props":{"label":"Send Message","variant":"primary"}}}', '{"op":"add","path":"/elements/submit","value":{"key":"submit","type":"Button","props":{"label":"Send Message","variant":"primary"}}}',
}, },
]; ];
File diff suppressed because one or more lines are too long
+6
View File
@@ -70,19 +70,25 @@ const spec: Spec = {
root: "container", root: "container",
elements: { elements: {
container: { container: {
key: "container",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "medium" }, props: { direction: "vertical", gap: "medium" },
children: ["heading", "metric"], children: ["heading", "metric"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Revenue", size: "large" }, props: { text: "Revenue", size: "large" },
children: [], children: [],
parentKey: "container",
}, },
metric: { metric: {
key: "metric",
type: "Metric", type: "Metric",
props: { label: "Total", value: "$12,345", format: "currency" }, props: { label: "Total", value: "$12,345", format: "currency" },
children: [], children: [],
parentKey: "container",
}, },
}, },
}; };
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "~~proprietary~~", "license": "~~proprietary~~",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@json-render/core": "workspace:*", "@json-render/core": "^0.4.0",
"@json-render/react": "workspace:*", "@json-render/react": "^0.4.0",
"@stripe/ui-extension-sdk": "^9.1.0", "@stripe/ui-extension-sdk": "^9.1.0",
"stripe": "^13.11.0" "stripe": "^13.11.0"
}, },
@@ -84,11 +84,14 @@ function createCustomerDetailSpec(
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["header", "details", "sections"], children: ["header", "details", "sections"],
parentKey: null,
}, },
header: { header: {
key: "header",
type: "Stack", type: "Stack",
props: { props: {
direction: "horizontal", direction: "horizontal",
@@ -96,28 +99,38 @@ function createCustomerDetailSpec(
distribute: "space-between", distribute: "space-between",
}, },
children: ["customerInfo", "actions"], children: ["customerInfo", "actions"],
parentKey: "root",
}, },
customerInfo: { customerInfo: {
key: "customerInfo",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "xsmall" }, props: { direction: "vertical", gap: "xsmall" },
children: ["customerName", "customerEmail"], children: ["customerName", "customerEmail"],
parentKey: "header",
}, },
customerName: { customerName: {
key: "customerName",
type: "Heading", type: "Heading",
props: { text: customer?.name ?? "Unknown Customer", size: "xlarge" }, props: { text: customer?.name ?? "Unknown Customer", size: "xlarge" },
children: [], children: [],
parentKey: "customerInfo",
}, },
customerEmail: { customerEmail: {
key: "customerEmail",
type: "Text", type: "Text",
props: { content: customer?.email ?? "", color: "secondary" }, props: { content: customer?.email ?? "", color: "secondary" },
children: [], children: [],
parentKey: "customerInfo",
}, },
actions: { actions: {
key: "actions",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["editBtn", "portalBtn"], children: ["editBtn", "portalBtn"],
parentKey: "header",
}, },
editBtn: { editBtn: {
key: "editBtn",
type: "Button", type: "Button",
props: { props: {
label: "Edit", label: "Edit",
@@ -126,8 +139,10 @@ function createCustomerDetailSpec(
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "actions",
}, },
portalBtn: { portalBtn: {
key: "portalBtn",
type: "Button", type: "Button",
props: { props: {
label: "Billing Portal", label: "Billing Portal",
@@ -136,23 +151,31 @@ function createCustomerDetailSpec(
type: "primary", type: "primary",
}, },
children: [], children: [],
parentKey: "actions",
}, },
details: { details: {
key: "details",
type: "PropertyList", type: "PropertyList",
props: { orientation: "horizontal" }, props: { orientation: "horizontal" },
children: ["detailStatus", "detailCreated", "detailBalance"], children: ["detailStatus", "detailCreated", "detailBalance"],
parentKey: "root",
}, },
detailStatus: { detailStatus: {
key: "detailStatus",
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Status", value: customer?.status ?? "Unknown" }, props: { label: "Status", value: customer?.status ?? "Unknown" },
children: [], children: [],
parentKey: "details",
}, },
detailCreated: { detailCreated: {
key: "detailCreated",
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Created", value: customer?.created ?? "Unknown" }, props: { label: "Created", value: customer?.created ?? "Unknown" },
children: [], children: [],
parentKey: "details",
}, },
detailBalance: { detailBalance: {
key: "detailBalance",
type: "PropertyListItem", type: "PropertyListItem",
props: { props: {
label: "Balance", label: "Balance",
@@ -161,32 +184,42 @@ function createCustomerDetailSpec(
: "$0.00", : "$0.00",
}, },
children: [], children: [],
parentKey: "details",
}, },
sections: { sections: {
key: "sections",
type: "Accordion", type: "Accordion",
props: {}, props: {},
children: ["paymentsSection", "subscriptionsSection", "invoicesSection"], children: ["paymentsSection", "subscriptionsSection", "invoicesSection"],
parentKey: "root",
}, },
// Payments Section // Payments Section
paymentsSection: { paymentsSection: {
key: "paymentsSection",
type: "AccordionItem", type: "AccordionItem",
props: { title: `Payments (${payments?.total ?? 0})`, defaultOpen: true }, props: { title: `Payments (${payments?.total ?? 0})`, defaultOpen: true },
children: paymentsList.length > 0 ? ["paymentsList"] : ["noPayments"], children: paymentsList.length > 0 ? ["paymentsList"] : ["noPayments"],
parentKey: "sections",
}, },
paymentsList: { paymentsList: {
key: "paymentsList",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 5).map((_, i) => `payment${i}`), children: paymentsList.slice(0, 5).map((_, i) => `payment${i}`),
parentKey: "paymentsSection",
}, },
noPayments: { noPayments: {
key: "noPayments",
type: "Text", type: "Text",
props: { content: "No payments found", color: "secondary" }, props: { content: "No payments found", color: "secondary" },
children: [], children: [],
parentKey: "paymentsSection",
}, },
// Subscriptions Section // Subscriptions Section
subscriptionsSection: { subscriptionsSection: {
key: "subscriptionsSection",
type: "AccordionItem", type: "AccordionItem",
props: { props: {
title: `Subscriptions (${subscriptions?.total ?? 0})`, title: `Subscriptions (${subscriptions?.total ?? 0})`,
@@ -196,42 +229,54 @@ function createCustomerDetailSpec(
subscriptionsList.length > 0 subscriptionsList.length > 0
? ["subscriptionsList"] ? ["subscriptionsList"]
: ["noSubscriptions"], : ["noSubscriptions"],
parentKey: "sections",
}, },
subscriptionsList: { subscriptionsList: {
key: "subscriptionsList",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: subscriptionsList.slice(0, 5).map((_, i) => `subscription${i}`), children: subscriptionsList.slice(0, 5).map((_, i) => `subscription${i}`),
parentKey: "subscriptionsSection",
}, },
noSubscriptions: { noSubscriptions: {
key: "noSubscriptions",
type: "Text", type: "Text",
props: { content: "No subscriptions found", color: "secondary" }, props: { content: "No subscriptions found", color: "secondary" },
children: [], children: [],
parentKey: "subscriptionsSection",
}, },
// Invoices Section // Invoices Section
invoicesSection: { invoicesSection: {
key: "invoicesSection",
type: "AccordionItem", type: "AccordionItem",
props: { props: {
title: `Invoices (${invoices?.total ?? 0})`, title: `Invoices (${invoices?.total ?? 0})`,
defaultOpen: false, defaultOpen: false,
}, },
children: invoicesList.length > 0 ? ["invoicesList"] : ["noInvoices"], children: invoicesList.length > 0 ? ["invoicesList"] : ["noInvoices"],
parentKey: "sections",
}, },
invoicesList: { invoicesList: {
key: "invoicesList",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 5).map((_, i) => `invoice${i}`), children: invoicesList.slice(0, 5).map((_, i) => `invoice${i}`),
parentKey: "invoicesSection",
}, },
noInvoices: { noInvoices: {
key: "noInvoices",
type: "Text", type: "Text",
props: { content: "No invoices found", color: "secondary" }, props: { content: "No invoices found", color: "secondary" },
children: [], children: [],
parentKey: "invoicesSection",
}, },
}; };
// Add payment cards // Add payment cards
paymentsList.slice(0, 5).forEach((p, i) => { paymentsList.slice(0, 5).forEach((p, i) => {
elements[`payment${i}`] = { elements[`payment${i}`] = {
key: `payment${i}`,
type: "PaymentCard", type: "PaymentCard",
props: { props: {
amount: 0, amount: 0,
@@ -241,12 +286,14 @@ function createCustomerDetailSpec(
paymentId: p.id, paymentId: p.id,
}, },
children: [], children: [],
parentKey: "paymentsList",
}; };
}); });
// Add subscription cards // Add subscription cards
subscriptionsList.slice(0, 5).forEach((s, i) => { subscriptionsList.slice(0, 5).forEach((s, i) => {
elements[`subscription${i}`] = { elements[`subscription${i}`] = {
key: `subscription${i}`,
type: "SubscriptionCard", type: "SubscriptionCard",
props: { props: {
planName: s.planName, planName: s.planName,
@@ -257,12 +304,14 @@ function createCustomerDetailSpec(
currentPeriodEnd: s.currentPeriodEnd, currentPeriodEnd: s.currentPeriodEnd,
}, },
children: [], children: [],
parentKey: "subscriptionsList",
}; };
}); });
// Add invoice cards // Add invoice cards
invoicesList.slice(0, 5).forEach((inv, i) => { invoicesList.slice(0, 5).forEach((inv, i) => {
elements[`invoice${i}`] = { elements[`invoice${i}`] = {
key: `invoice${i}`,
type: "InvoiceCard", type: "InvoiceCard",
props: { props: {
invoiceNumber: inv.invoiceNumber, invoiceNumber: inv.invoiceNumber,
@@ -272,6 +321,7 @@ function createCustomerDetailSpec(
dueDate: inv.dueDate, dueDate: inv.dueDate,
}, },
children: [], children: [],
parentKey: "invoicesList",
}; };
}); });
@@ -36,21 +36,28 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "actions", "list", "pagination"], children: ["heading", "stats", "actions", "list", "pagination"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Customer Directory", size: "xlarge" }, props: { text: "Customer Directory", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
stats: { stats: {
key: "stats",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["totalMetric"], children: ["totalMetric"],
parentKey: "root",
}, },
totalMetric: { totalMetric: {
key: "totalMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Customers", label: "Total Customers",
@@ -58,13 +65,17 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "stats",
}, },
actions: { actions: {
key: "actions",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"], children: ["refreshBtn", "exportBtn"],
parentKey: "root",
}, },
refreshBtn: { refreshBtn: {
key: "refreshBtn",
type: "Button", type: "Button",
props: { props: {
label: "Refresh", label: "Refresh",
@@ -72,8 +83,10 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "actions",
}, },
exportBtn: { exportBtn: {
key: "exportBtn",
type: "Button", type: "Button",
props: { props: {
label: "Export CSV", label: "Export CSV",
@@ -82,18 +95,24 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "actions",
}, },
list: { list: {
key: "list",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: customerList.slice(0, 10).map((_, i) => `customer${i}`), children: customerList.slice(0, 10).map((_, i) => `customer${i}`),
parentKey: "root",
}, },
pagination: { pagination: {
key: "pagination",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: customers?.hasMore ? ["loadMore"] : [], children: customers?.hasMore ? ["loadMore"] : [],
parentKey: "root",
}, },
loadMore: { loadMore: {
key: "loadMore",
type: "Button", type: "Button",
props: { props: {
label: "Load More", label: "Load More",
@@ -105,11 +124,13 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "pagination",
}, },
}; };
customerList.slice(0, 10).forEach((c, i) => { customerList.slice(0, 10).forEach((c, i) => {
elements[`customer${i}`] = { elements[`customer${i}`] = {
key: `customer${i}`,
type: "CustomerCard", type: "CustomerCard",
props: { props: {
name: c.name, name: c.name,
@@ -118,6 +139,7 @@ function createCustomersListSpec(data: Record<string, unknown>): Spec {
customerId: c.id, customerId: c.id,
}, },
children: [], children: [],
parentKey: "list",
}; };
}); });
+82
View File
@@ -38,21 +38,28 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "refresh"], children: ["heading", "metrics", "refresh"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Revenue Dashboard", size: "xlarge" }, props: { text: "Revenue Dashboard", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"], children: ["m1", "m2", "m3"],
parentKey: "root",
}, },
m1: { m1: {
key: "m1",
type: "Metric", type: "Metric",
props: { props: {
label: "Payment Volume", label: "Payment Volume",
@@ -63,8 +70,10 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
m2: { m2: {
key: "m2",
type: "Metric", type: "Metric",
props: { props: {
label: "Active Subscriptions", label: "Active Subscriptions",
@@ -75,8 +84,10 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
m3: { m3: {
key: "m3",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Customers", label: "Total Customers",
@@ -84,8 +95,10 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
refresh: { refresh: {
key: "refresh",
type: "Button", type: "Button",
props: { props: {
label: "Refresh Data", label: "Refresh Data",
@@ -93,6 +106,7 @@ function createRevenueSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "root",
}, },
}, },
}; };
@@ -115,21 +129,28 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "payments", "refresh"], children: ["heading", "stats", "payments", "refresh"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Recent Payments", size: "xlarge" }, props: { text: "Recent Payments", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
stats: { stats: {
key: "stats",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["s1", "s2"], children: ["s1", "s2"],
parentKey: "root",
}, },
s1: { s1: {
key: "s1",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Volume", label: "Total Volume",
@@ -137,8 +158,10 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "stats",
}, },
s2: { s2: {
key: "s2",
type: "Metric", type: "Metric",
props: { props: {
label: "Success Rate", label: "Success Rate",
@@ -146,13 +169,17 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "stats",
}, },
payments: { payments: {
key: "payments",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 5).map((_, i) => `p${i}`), children: paymentsList.slice(0, 5).map((_, i) => `p${i}`),
parentKey: "root",
}, },
refresh: { refresh: {
key: "refresh",
type: "Button", type: "Button",
props: { props: {
label: "Refresh Payments", label: "Refresh Payments",
@@ -160,11 +187,13 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "root",
}, },
}; };
paymentsList.slice(0, 5).forEach((p, i) => { paymentsList.slice(0, 5).forEach((p, i) => {
elements[`p${i}`] = { elements[`p${i}`] = {
key: `p${i}`,
type: "PaymentCard", type: "PaymentCard",
props: { props: {
amount: 0, // We'll use formattedAmount in description amount: 0, // We'll use formattedAmount in description
@@ -174,6 +203,7 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
paymentId: p.id, paymentId: p.id,
}, },
children: [], children: [],
parentKey: "payments",
}; };
}); });
@@ -199,21 +229,28 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "alert", "subs", "refresh"], children: ["heading", "metrics", "alert", "subs", "refresh"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Subscriptions Overview", size: "xlarge" }, props: { text: "Subscriptions Overview", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["m1", "m2", "m3"], children: ["m1", "m2", "m3"],
parentKey: "root",
}, },
m1: { m1: {
key: "m1",
type: "Metric", type: "Metric",
props: { props: {
label: "Active", label: "Active",
@@ -221,8 +258,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
m2: { m2: {
key: "m2",
type: "Metric", type: "Metric",
props: { props: {
label: "Trialing", label: "Trialing",
@@ -230,8 +269,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
m3: { m3: {
key: "m3",
type: "Metric", type: "Metric",
props: { props: {
label: "Past Due", label: "Past Due",
@@ -239,21 +280,27 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: subscriptions?.pastDue ? "negative" : "neutral", changeType: subscriptions?.pastDue ? "negative" : "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
alert: { alert: {
key: "alert",
type: "Banner", type: "Banner",
props: { props: {
title: `${subscriptions?.trialing ?? 0} subscriptions currently trialing`, title: `${subscriptions?.trialing ?? 0} subscriptions currently trialing`,
type: "default", type: "default",
}, },
children: [], children: [],
parentKey: "root",
}, },
subs: { subs: {
key: "subs",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: subsList.slice(0, 5).map((_, i) => `sub${i}`), children: subsList.slice(0, 5).map((_, i) => `sub${i}`),
parentKey: "root",
}, },
refresh: { refresh: {
key: "refresh",
type: "Button", type: "Button",
props: { props: {
label: "Refresh Subscriptions", label: "Refresh Subscriptions",
@@ -261,11 +308,13 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "root",
}, },
}; };
subsList.slice(0, 5).forEach((s, i) => { subsList.slice(0, 5).forEach((s, i) => {
elements[`sub${i}`] = { elements[`sub${i}`] = {
key: `sub${i}`,
type: "SubscriptionCard", type: "SubscriptionCard",
props: { props: {
planName: s.planName, planName: s.planName,
@@ -274,6 +323,7 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
interval: (s.interval as "month" | "year") ?? "month", interval: (s.interval as "month" | "year") ?? "month",
}, },
children: [], children: [],
parentKey: "subs",
}; };
}); });
@@ -296,21 +346,28 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "customers", "refresh"], children: ["heading", "stats", "customers", "refresh"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Customer Directory", size: "xlarge" }, props: { text: "Customer Directory", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
stats: { stats: {
key: "stats",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["s1"], children: ["s1"],
parentKey: "root",
}, },
s1: { s1: {
key: "s1",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Customers", label: "Total Customers",
@@ -318,13 +375,17 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "stats",
}, },
customers: { customers: {
key: "customers",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: customersList.slice(0, 5).map((_, i) => `c${i}`), children: customersList.slice(0, 5).map((_, i) => `c${i}`),
parentKey: "root",
}, },
refresh: { refresh: {
key: "refresh",
type: "Button", type: "Button",
props: { props: {
label: "Refresh Customers", label: "Refresh Customers",
@@ -332,11 +393,13 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "root",
}, },
}; };
customersList.slice(0, 5).forEach((c, i) => { customersList.slice(0, 5).forEach((c, i) => {
elements[`c${i}`] = { elements[`c${i}`] = {
key: `c${i}`,
type: "CustomerCard", type: "CustomerCard",
props: { props: {
name: c.name, name: c.name,
@@ -345,6 +408,7 @@ function createCustomersSpec(data: Record<string, unknown>): Spec {
customerId: c.id, customerId: c.id,
}, },
children: [], children: [],
parentKey: "customers",
}; };
}); });
@@ -370,21 +434,28 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "stats", "invoices", "refresh"], children: ["heading", "stats", "invoices", "refresh"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Invoice Management", size: "xlarge" }, props: { text: "Invoice Management", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
stats: { stats: {
key: "stats",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["s1", "s2", "s3"], children: ["s1", "s2", "s3"],
parentKey: "root",
}, },
s1: { s1: {
key: "s1",
type: "Metric", type: "Metric",
props: { props: {
label: "Outstanding", label: "Outstanding",
@@ -392,8 +463,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "stats",
}, },
s2: { s2: {
key: "s2",
type: "Metric", type: "Metric",
props: { props: {
label: "Paid", label: "Paid",
@@ -401,8 +474,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "stats",
}, },
s3: { s3: {
key: "s3",
type: "Metric", type: "Metric",
props: { props: {
label: "Overdue", label: "Overdue",
@@ -410,13 +485,17 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral", changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
}, },
children: [], children: [],
parentKey: "stats",
}, },
invoices: { invoices: {
key: "invoices",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 5).map((_, i) => `inv${i}`), children: invoicesList.slice(0, 5).map((_, i) => `inv${i}`),
parentKey: "root",
}, },
refresh: { refresh: {
key: "refresh",
type: "Button", type: "Button",
props: { props: {
label: "Refresh Invoices", label: "Refresh Invoices",
@@ -424,11 +503,13 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "root",
}, },
}; };
invoicesList.slice(0, 5).forEach((inv, i) => { invoicesList.slice(0, 5).forEach((inv, i) => {
elements[`inv${i}`] = { elements[`inv${i}`] = {
key: `inv${i}`,
type: "InvoiceCard", type: "InvoiceCard",
props: { props: {
invoiceNumber: inv.invoiceNumber, invoiceNumber: inv.invoiceNumber,
@@ -437,6 +518,7 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
dueDate: inv.dueDate, dueDate: inv.dueDate,
}, },
children: [], children: [],
parentKey: "invoices",
}; };
}); });
@@ -41,6 +41,7 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: [ children: [
@@ -51,18 +52,24 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
"list", "list",
"pagination", "pagination",
], ],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Invoices", size: "xlarge" }, props: { text: "Invoices", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["outstandingMetric", "paidMetric", "overdueMetric"], children: ["outstandingMetric", "paidMetric", "overdueMetric"],
parentKey: "root",
}, },
outstandingMetric: { outstandingMetric: {
key: "outstandingMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Outstanding", label: "Outstanding",
@@ -70,8 +77,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
paidMetric: { paidMetric: {
key: "paidMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Paid", label: "Paid",
@@ -79,8 +88,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
overdueMetric: { overdueMetric: {
key: "overdueMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Overdue", label: "Overdue",
@@ -88,8 +99,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral", changeType: invoices?.overdue !== "$0.00" ? "negative" : "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
alert: { alert: {
key: "alert",
type: "Banner", type: "Banner",
props: { props: {
title: title:
@@ -99,18 +112,24 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: invoices?.overdue !== "$0.00" ? "caution" : "default", type: invoices?.overdue !== "$0.00" ? "caution" : "default",
}, },
children: [], children: [],
parentKey: "root",
}, },
filters: { filters: {
key: "filters",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "createBtn", "exportBtn"], children: ["refreshBtn", "createBtn", "exportBtn"],
parentKey: "root",
}, },
refreshBtn: { refreshBtn: {
key: "refreshBtn",
type: "Button", type: "Button",
props: { label: "Refresh", action: "refreshInvoices", type: "secondary" }, props: { label: "Refresh", action: "refreshInvoices", type: "secondary" },
children: [], children: [],
parentKey: "filters",
}, },
createBtn: { createBtn: {
key: "createBtn",
type: "Button", type: "Button",
props: { props: {
label: "Create Invoice", label: "Create Invoice",
@@ -119,8 +138,10 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "primary", type: "primary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
exportBtn: { exportBtn: {
key: "exportBtn",
type: "Button", type: "Button",
props: { props: {
label: "Export CSV", label: "Export CSV",
@@ -129,18 +150,24 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
list: { list: {
key: "list",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`), children: invoicesList.slice(0, 10).map((_, i) => `invoice${i}`),
parentKey: "root",
}, },
pagination: { pagination: {
key: "pagination",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: invoices?.hasMore ? ["loadMore"] : [], children: invoices?.hasMore ? ["loadMore"] : [],
parentKey: "root",
}, },
loadMore: { loadMore: {
key: "loadMore",
type: "Button", type: "Button",
props: { props: {
label: "Load More", label: "Load More",
@@ -149,11 +176,13 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "pagination",
}, },
}; };
invoicesList.slice(0, 10).forEach((inv, i) => { invoicesList.slice(0, 10).forEach((inv, i) => {
elements[`invoice${i}`] = { elements[`invoice${i}`] = {
key: `invoice${i}`,
type: "InvoiceCard", type: "InvoiceCard",
props: { props: {
invoiceNumber: inv.invoiceNumber, invoiceNumber: inv.invoiceNumber,
@@ -169,6 +198,7 @@ function createInvoicesSpec(data: Record<string, unknown>): Spec {
customerEmail: inv.customerEmail, customerEmail: inv.customerEmail,
}, },
children: [], children: [],
parentKey: "list",
}; };
}); });
@@ -56,11 +56,14 @@ function createPaymentDetailSpec(
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["header", "details", "actions", "refundsSection"], children: ["header", "details", "actions", "refundsSection"],
parentKey: null,
}, },
header: { header: {
key: "header",
type: "Stack", type: "Stack",
props: { props: {
direction: "horizontal", direction: "horizontal",
@@ -68,23 +71,31 @@ function createPaymentDetailSpec(
distribute: "space-between", distribute: "space-between",
}, },
children: ["paymentInfo", "statusBadge"], children: ["paymentInfo", "statusBadge"],
parentKey: "root",
}, },
paymentInfo: { paymentInfo: {
key: "paymentInfo",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "xsmall" }, props: { direction: "vertical", gap: "xsmall" },
children: ["paymentAmount", "paymentId"], children: ["paymentAmount", "paymentId"],
parentKey: "header",
}, },
paymentAmount: { paymentAmount: {
key: "paymentAmount",
type: "Heading", type: "Heading",
props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" }, props: { text: payment?.formattedAmount ?? "$0.00", size: "xlarge" },
children: [], children: [],
parentKey: "paymentInfo",
}, },
paymentId: { paymentId: {
key: "paymentId",
type: "Text", type: "Text",
props: { content: paymentId, color: "secondary", size: "small" }, props: { content: paymentId, color: "secondary", size: "small" },
children: [], children: [],
parentKey: "paymentInfo",
}, },
statusBadge: { statusBadge: {
key: "statusBadge",
type: "Badge", type: "Badge",
props: { props: {
label: payment?.status ?? "unknown", label: payment?.status ?? "unknown",
@@ -96,8 +107,10 @@ function createPaymentDetailSpec(
: "negative", : "negative",
}, },
children: [], children: [],
parentKey: "header",
}, },
details: { details: {
key: "details",
type: "PropertyList", type: "PropertyList",
props: { orientation: "vertical" }, props: { orientation: "vertical" },
children: [ children: [
@@ -106,42 +119,54 @@ function createPaymentDetailSpec(
"detailCurrency", "detailCurrency",
"detailCustomer", "detailCustomer",
], ],
parentKey: "root",
}, },
detailDescription: { detailDescription: {
key: "detailDescription",
type: "PropertyListItem", type: "PropertyListItem",
props: { props: {
label: "Description", label: "Description",
value: payment?.description ?? "No description", value: payment?.description ?? "No description",
}, },
children: [], children: [],
parentKey: "details",
}, },
detailCreated: { detailCreated: {
key: "detailCreated",
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Created", value: payment?.created ?? "Unknown" }, props: { label: "Created", value: payment?.created ?? "Unknown" },
children: [], children: [],
parentKey: "details",
}, },
detailCurrency: { detailCurrency: {
key: "detailCurrency",
type: "PropertyListItem", type: "PropertyListItem",
props: { props: {
label: "Currency", label: "Currency",
value: (payment?.currency ?? "usd").toUpperCase(), value: (payment?.currency ?? "usd").toUpperCase(),
}, },
children: [], children: [],
parentKey: "details",
}, },
detailCustomer: { detailCustomer: {
key: "detailCustomer",
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Customer", value: payment?.customerId ?? "Guest" }, props: { label: "Customer", value: payment?.customerId ?? "Guest" },
children: [], children: [],
parentKey: "details",
}, },
actions: { actions: {
key: "actions",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: children:
payment?.status === "succeeded" payment?.status === "succeeded"
? ["refundBtn", "viewBtn"] ? ["refundBtn", "viewBtn"]
: ["viewBtn"], : ["viewBtn"],
parentKey: "root",
}, },
refundBtn: { refundBtn: {
key: "refundBtn",
type: "Button", type: "Button",
props: { props: {
label: "Refund Payment", label: "Refund Payment",
@@ -150,8 +175,10 @@ function createPaymentDetailSpec(
type: "destructive", type: "destructive",
}, },
children: [], children: [],
parentKey: "actions",
}, },
viewBtn: { viewBtn: {
key: "viewBtn",
type: "Button", type: "Button",
props: { props: {
label: "View in Dashboard", label: "View in Dashboard",
@@ -160,34 +187,44 @@ function createPaymentDetailSpec(
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "actions",
}, },
refundsSection: { refundsSection: {
key: "refundsSection",
type: "Accordion", type: "Accordion",
props: {}, props: {},
children: ["refundsAccordion"], children: ["refundsAccordion"],
parentKey: "root",
}, },
refundsAccordion: { refundsAccordion: {
key: "refundsAccordion",
type: "AccordionItem", type: "AccordionItem",
props: { props: {
title: `Refunds (${refunds?.total ?? 0})`, title: `Refunds (${refunds?.total ?? 0})`,
defaultOpen: refundsList.length > 0, defaultOpen: refundsList.length > 0,
}, },
children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"], children: refundsList.length > 0 ? ["refundsList"] : ["noRefunds"],
parentKey: "refundsSection",
}, },
refundsList: { refundsList: {
key: "refundsList",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: refundsList.slice(0, 5).map((_, i) => `refund${i}`), children: refundsList.slice(0, 5).map((_, i) => `refund${i}`),
parentKey: "refundsAccordion",
}, },
noRefunds: { noRefunds: {
key: "noRefunds",
type: "Text", type: "Text",
props: { content: "No refunds", color: "secondary" }, props: { content: "No refunds", color: "secondary" },
children: [], children: [],
parentKey: "refundsAccordion",
}, },
}; };
refundsList.slice(0, 5).forEach((r, i) => { refundsList.slice(0, 5).forEach((r, i) => {
elements[`refund${i}`] = { elements[`refund${i}`] = {
key: `refund${i}`,
type: "RefundCard", type: "RefundCard",
props: { props: {
amount: r.amount, amount: r.amount,
@@ -196,6 +233,7 @@ function createPaymentDetailSpec(
reason: r.reason, reason: r.reason,
}, },
children: [], children: [],
parentKey: "refundsList",
}; };
}); });
@@ -38,21 +38,28 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "filters", "list", "pagination"], children: ["heading", "metrics", "filters", "list", "pagination"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Payments", size: "xlarge" }, props: { text: "Payments", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["volumeMetric", "rateMetric", "countMetric"], children: ["volumeMetric", "rateMetric", "countMetric"],
parentKey: "root",
}, },
volumeMetric: { volumeMetric: {
key: "volumeMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Volume", label: "Total Volume",
@@ -60,8 +67,10 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
rateMetric: { rateMetric: {
key: "rateMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Success Rate", label: "Success Rate",
@@ -69,8 +78,10 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
countMetric: { countMetric: {
key: "countMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Payments", label: "Total Payments",
@@ -78,18 +89,24 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
filters: { filters: {
key: "filters",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"], children: ["refreshBtn", "exportBtn"],
parentKey: "root",
}, },
refreshBtn: { refreshBtn: {
key: "refreshBtn",
type: "Button", type: "Button",
props: { label: "Refresh", action: "refreshPayments", type: "secondary" }, props: { label: "Refresh", action: "refreshPayments", type: "secondary" },
children: [], children: [],
parentKey: "filters",
}, },
exportBtn: { exportBtn: {
key: "exportBtn",
type: "Button", type: "Button",
props: { props: {
label: "Export CSV", label: "Export CSV",
@@ -98,18 +115,24 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
list: { list: {
key: "list",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: paymentsList.slice(0, 10).map((_, i) => `payment${i}`), children: paymentsList.slice(0, 10).map((_, i) => `payment${i}`),
parentKey: "root",
}, },
pagination: { pagination: {
key: "pagination",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: payments?.hasMore ? ["loadMore"] : [], children: payments?.hasMore ? ["loadMore"] : [],
parentKey: "root",
}, },
loadMore: { loadMore: {
key: "loadMore",
type: "Button", type: "Button",
props: { props: {
label: "Load More", label: "Load More",
@@ -121,11 +144,13 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "pagination",
}, },
}; };
paymentsList.slice(0, 10).forEach((p, i) => { paymentsList.slice(0, 10).forEach((p, i) => {
elements[`payment${i}`] = { elements[`payment${i}`] = {
key: `payment${i}`,
type: "PaymentCard", type: "PaymentCard",
props: { props: {
amount: 0, amount: 0,
@@ -135,6 +160,7 @@ function createPaymentsSpec(data: Record<string, unknown>): Spec {
paymentId: p.id, paymentId: p.id,
}, },
children: [], children: [],
parentKey: "list",
}; };
}); });
@@ -49,21 +49,28 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: ["heading", "metrics", "filters", "list", "pagination"], children: ["heading", "metrics", "filters", "list", "pagination"],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Products & Prices", size: "xlarge" }, props: { text: "Products & Prices", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: ["productsMetric", "pricesMetric", "activeMetric"], children: ["productsMetric", "pricesMetric", "activeMetric"],
parentKey: "root",
}, },
productsMetric: { productsMetric: {
key: "productsMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Products", label: "Total Products",
@@ -71,8 +78,10 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
pricesMetric: { pricesMetric: {
key: "pricesMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Total Prices", label: "Total Prices",
@@ -80,8 +89,10 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
activeMetric: { activeMetric: {
key: "activeMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Active Products", label: "Active Products",
@@ -89,18 +100,24 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
filters: { filters: {
key: "filters",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "createBtn"], children: ["refreshBtn", "createBtn"],
parentKey: "root",
}, },
refreshBtn: { refreshBtn: {
key: "refreshBtn",
type: "Button", type: "Button",
props: { label: "Refresh", action: "fetchProducts", type: "secondary" }, props: { label: "Refresh", action: "fetchProducts", type: "secondary" },
children: [], children: [],
parentKey: "filters",
}, },
createBtn: { createBtn: {
key: "createBtn",
type: "Button", type: "Button",
props: { props: {
label: "Create Product", label: "Create Product",
@@ -109,18 +126,24 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
type: "primary", type: "primary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
list: { list: {
key: "list",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: productsList.slice(0, 10).map((_, i) => `product${i}`), children: productsList.slice(0, 10).map((_, i) => `product${i}`),
parentKey: "root",
}, },
pagination: { pagination: {
key: "pagination",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: products?.hasMore ? ["loadMore"] : [], children: products?.hasMore ? ["loadMore"] : [],
parentKey: "root",
}, },
loadMore: { loadMore: {
key: "loadMore",
type: "Button", type: "Button",
props: { props: {
label: "Load More", label: "Load More",
@@ -129,6 +152,7 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "pagination",
}, },
}; };
@@ -145,12 +169,15 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
: "No prices"; : "No prices";
elements[`product${i}`] = { elements[`product${i}`] = {
key: `product${i}`,
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: [`productCard${i}`], children: [`productCard${i}`],
parentKey: "list",
}; };
elements[`productCard${i}`] = { elements[`productCard${i}`] = {
key: `productCard${i}`,
type: "PropertyList", type: "PropertyList",
props: { orientation: "vertical" }, props: { orientation: "vertical" },
children: [ children: [
@@ -159,30 +186,39 @@ function createProductsSpec(data: Record<string, unknown>): Spec {
`productPrice${i}`, `productPrice${i}`,
`productStatus${i}`, `productStatus${i}`,
], ],
parentKey: `product${i}`,
}; };
elements[`productName${i}`] = { elements[`productName${i}`] = {
key: `productName${i}`,
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Name", value: p.name }, props: { label: "Name", value: p.name },
children: [], children: [],
parentKey: `productCard${i}`,
}; };
elements[`productDesc${i}`] = { elements[`productDesc${i}`] = {
key: `productDesc${i}`,
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Description", value: p.description || "No description" }, props: { label: "Description", value: p.description || "No description" },
children: [], children: [],
parentKey: `productCard${i}`,
}; };
elements[`productPrice${i}`] = { elements[`productPrice${i}`] = {
key: `productPrice${i}`,
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Prices", value: priceDisplay }, props: { label: "Prices", value: priceDisplay },
children: [], children: [],
parentKey: `productCard${i}`,
}; };
elements[`productStatus${i}`] = { elements[`productStatus${i}`] = {
key: `productStatus${i}`,
type: "PropertyListItem", type: "PropertyListItem",
props: { label: "Status", value: p.active ? "Active" : "Archived" }, props: { label: "Status", value: p.active ? "Active" : "Archived" },
children: [], children: [],
parentKey: `productCard${i}`,
}; };
}); });
@@ -42,6 +42,7 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
const elements: Spec["elements"] = { const elements: Spec["elements"] = {
root: { root: {
key: "root",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "large" }, props: { direction: "vertical", gap: "large" },
children: [ children: [
@@ -52,13 +53,17 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
"list", "list",
"pagination", "pagination",
], ],
parentKey: null,
}, },
heading: { heading: {
key: "heading",
type: "Heading", type: "Heading",
props: { text: "Subscriptions", size: "xlarge" }, props: { text: "Subscriptions", size: "xlarge" },
children: [], children: [],
parentKey: "root",
}, },
metrics: { metrics: {
key: "metrics",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "medium" }, props: { direction: "horizontal", gap: "medium" },
children: [ children: [
@@ -67,8 +72,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
"pastDueMetric", "pastDueMetric",
"canceledMetric", "canceledMetric",
], ],
parentKey: "root",
}, },
activeMetric: { activeMetric: {
key: "activeMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Active", label: "Active",
@@ -76,8 +83,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
trialingMetric: { trialingMetric: {
key: "trialingMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Trialing", label: "Trialing",
@@ -85,8 +94,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "positive", changeType: "positive",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
pastDueMetric: { pastDueMetric: {
key: "pastDueMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Past Due", label: "Past Due",
@@ -94,8 +105,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: subscriptions?.pastDue ? "negative" : "neutral", changeType: subscriptions?.pastDue ? "negative" : "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
canceledMetric: { canceledMetric: {
key: "canceledMetric",
type: "Metric", type: "Metric",
props: { props: {
label: "Canceled", label: "Canceled",
@@ -103,8 +116,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
changeType: "neutral", changeType: "neutral",
}, },
children: [], children: [],
parentKey: "metrics",
}, },
alert: { alert: {
key: "alert",
type: "Banner", type: "Banner",
props: { props: {
title: subscriptions?.pastDue title: subscriptions?.pastDue
@@ -113,13 +128,17 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: subscriptions?.pastDue ? "caution" : "default", type: subscriptions?.pastDue ? "caution" : "default",
}, },
children: [], children: [],
parentKey: "root",
}, },
filters: { filters: {
key: "filters",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: ["refreshBtn", "exportBtn"], children: ["refreshBtn", "exportBtn"],
parentKey: "root",
}, },
refreshBtn: { refreshBtn: {
key: "refreshBtn",
type: "Button", type: "Button",
props: { props: {
label: "Refresh", label: "Refresh",
@@ -127,8 +146,10 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
exportBtn: { exportBtn: {
key: "exportBtn",
type: "Button", type: "Button",
props: { props: {
label: "Export CSV", label: "Export CSV",
@@ -137,20 +158,26 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "filters",
}, },
list: { list: {
key: "list",
type: "Stack", type: "Stack",
props: { direction: "vertical", gap: "small" }, props: { direction: "vertical", gap: "small" },
children: subscriptionsList children: subscriptionsList
.slice(0, 10) .slice(0, 10)
.map((_, i) => `subscription${i}`), .map((_, i) => `subscription${i}`),
parentKey: "root",
}, },
pagination: { pagination: {
key: "pagination",
type: "Stack", type: "Stack",
props: { direction: "horizontal", gap: "small" }, props: { direction: "horizontal", gap: "small" },
children: subscriptions?.hasMore ? ["loadMore"] : [], children: subscriptions?.hasMore ? ["loadMore"] : [],
parentKey: "root",
}, },
loadMore: { loadMore: {
key: "loadMore",
type: "Button", type: "Button",
props: { props: {
label: "Load More", label: "Load More",
@@ -159,11 +186,13 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
type: "secondary", type: "secondary",
}, },
children: [], children: [],
parentKey: "pagination",
}, },
}; };
subscriptionsList.slice(0, 10).forEach((s, i) => { subscriptionsList.slice(0, 10).forEach((s, i) => {
elements[`subscription${i}`] = { elements[`subscription${i}`] = {
key: `subscription${i}`,
type: "SubscriptionCard", type: "SubscriptionCard",
props: { props: {
planName: s.planName, planName: s.planName,
@@ -179,6 +208,7 @@ function createSubscriptionsSpec(data: Record<string, unknown>): Spec {
currentPeriodEnd: s.currentPeriodEnd, currentPeriodEnd: s.currentPeriodEnd,
}, },
children: [], children: [],
parentKey: "list",
}; };
}); });
-25
View File
@@ -1,25 +0,0 @@
# @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", "name": "@json-render/codegen",
"version": "0.4.4", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees", "description": "Utilities for generating code from json-render UI trees",
"keywords": [ "keywords": [
+13 -4
View File
@@ -13,15 +13,18 @@ describe("traverseSpec", () => {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Card", type: "Card",
props: {}, props: {},
children: ["child1", "child2"], children: ["child1", "child2"],
}, },
child1: { child1: {
key: "child1",
type: "Text", type: "Text",
props: {}, props: {},
}, },
child2: { child2: {
key: "child2",
type: "Button", type: "Button",
props: {}, props: {},
}, },
@@ -29,8 +32,8 @@ describe("traverseSpec", () => {
}; };
const visited: string[] = []; const visited: string[] = [];
traverseSpec(spec, (_element, key) => { traverseSpec(spec, (element) => {
visited.push(key); visited.push(element.key);
}); });
expect(visited).toEqual(["root", "child1", "child2"]); expect(visited).toEqual(["root", "child1", "child2"]);
@@ -38,8 +41,8 @@ describe("traverseSpec", () => {
it("handles empty spec", () => { it("handles empty spec", () => {
const visited: string[] = []; const visited: string[] = [];
traverseSpec(null as unknown as Spec, (_element, key) => { traverseSpec(null as unknown as Spec, (element) => {
visited.push(key); visited.push(element.key);
}); });
expect(visited).toEqual([]); expect(visited).toEqual([]);
}); });
@@ -51,15 +54,18 @@ describe("collectUsedComponents", () => {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Card", type: "Card",
props: {}, props: {},
children: ["child1", "child2"], children: ["child1", "child2"],
}, },
child1: { child1: {
key: "child1",
type: "Text", type: "Text",
props: {}, props: {},
}, },
child2: { child2: {
key: "child2",
type: "Text", type: "Text",
props: {}, props: {},
}, },
@@ -77,6 +83,7 @@ describe("collectDataPaths", () => {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Metric", type: "Metric",
props: { valuePath: "analytics/revenue" }, props: { valuePath: "analytics/revenue" },
}, },
@@ -92,6 +99,7 @@ describe("collectDataPaths", () => {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Text", type: "Text",
props: { content: { path: "user/name" } }, props: { content: { path: "user/name" } },
}, },
@@ -109,6 +117,7 @@ describe("collectActions", () => {
root: "root", root: "root",
elements: { elements: {
root: { root: {
key: "root",
type: "Button", type: "Button",
props: { action: "submit_form" }, props: { action: "submit_form" },
}, },
+5 -10
View File
@@ -4,12 +4,7 @@ import type { Spec, UIElement } from "@json-render/core";
* Visitor function for spec traversal * Visitor function for spec traversal
*/ */
export interface TreeVisitor { export interface TreeVisitor {
( (element: UIElement, depth: number, parent: UIElement | null): void;
element: UIElement,
key: string,
depth: number,
parent: UIElement | null,
): void;
} }
/** /**
@@ -30,7 +25,7 @@ export function traverseSpec(
const element = spec.elements[key]; const element = spec.elements[key];
if (!element) return; if (!element) return;
visitor(element, key, depth, parent); visitor(element, depth, parent);
if (element.children) { if (element.children) {
for (const childKey of element.children) { for (const childKey of element.children) {
@@ -48,7 +43,7 @@ export function traverseSpec(
export function collectUsedComponents(spec: Spec): Set<string> { export function collectUsedComponents(spec: Spec): Set<string> {
const components = new Set<string>(); const components = new Set<string>();
traverseSpec(spec, (element, _key) => { traverseSpec(spec, (element) => {
components.add(element.type); components.add(element.type);
}); });
@@ -61,7 +56,7 @@ export function collectUsedComponents(spec: Spec): Set<string> {
export function collectDataPaths(spec: Spec): Set<string> { export function collectDataPaths(spec: Spec): Set<string> {
const paths = new Set<string>(); const paths = new Set<string>();
traverseSpec(spec, (element, _key) => { traverseSpec(spec, (element) => {
// Check props for data paths // Check props for data paths
for (const [propName, propValue] of Object.entries(element.props)) { for (const [propName, propValue] of Object.entries(element.props)) {
// Check for path props (e.g., valuePath, dataPath, bindPath) // Check for path props (e.g., valuePath, dataPath, bindPath)
@@ -146,7 +141,7 @@ function collectPathsFromCondition(
export function collectActions(spec: Spec): Set<string> { export function collectActions(spec: Spec): Set<string> {
const actions = new Set<string>(); const actions = new Set<string>();
traverseSpec(spec, (element, _key) => { traverseSpec(spec, (element) => {
for (const propValue of Object.values(element.props)) { for (const propValue of Object.values(element.props)) {
// Check for action prop (string action name) // Check for action prop (string action name)
if (typeof propValue === "string" && propValue.startsWith("action:")) { if (typeof propValue === "string" && propValue.startsWith("action:")) {
-19
View File
@@ -1,19 +0,0 @@
# @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
+6 -8
View File
@@ -116,16 +116,14 @@ while (streaming) {
const finalSpec = compiler.getResult(); const finalSpec = compiler.getResult();
``` ```
SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch): SpecStream format (each line is a JSON patch):
```jsonl ```jsonl
{"op":"add","path":"/root/type","value":"Card"} {"op":"set","path":"/root/type","value":"Card"}
{"op":"add","path":"/root/props","value":{"title":"Hello"}} {"op":"set","path":"/root/props","value":{"title":"Hello"}}
{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}} {"op":"set","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 ### Low-Level Utilities
```typescript ```typescript
@@ -136,8 +134,8 @@ import {
} from "@json-render/core"; } from "@json-render/core";
// Parse a single line // Parse a single line
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}'); const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
// { op: "add", path: "/root", value: {} } // { op: "set", path: "/root", value: {} }
// Apply a patch to an object // Apply a patch to an object
const obj = {}; const obj = {};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/core", "name": "@json-render/core",
"version": "0.4.4", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.", "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [ "keywords": [
+10 -2
View File
@@ -72,10 +72,12 @@ describe("createCatalog", () => {
}); });
const validElement = { const validElement = {
key: "1",
type: "text", type: "text",
props: { content: "Hello" }, props: { content: "Hello" },
}; };
const invalidElement = { const invalidElement = {
key: "1",
type: "text", type: "text",
props: { content: 123 }, props: { content: 123 },
}; };
@@ -94,7 +96,7 @@ describe("createCatalog", () => {
const validSpec = { const validSpec = {
root: "1", root: "1",
elements: { elements: {
"1": { type: "text", props: { content: "Hello" } }, "1": { key: "1", type: "text", props: { content: "Hello" } },
}, },
}; };
@@ -118,13 +120,16 @@ describe("createCatalog", () => {
root: "card-1", root: "card-1",
elements: { elements: {
"card-1": { "card-1": {
key: "card-1",
type: "card", type: "card",
props: { title: "Hello" }, props: { title: "Hello" },
children: ["text-1"], children: ["text-1"],
}, },
"text-1": { "text-1": {
key: "text-1",
type: "text", type: "text",
props: { content: "World" }, props: { content: "World" },
parentKey: "card-1",
}, },
}, },
}; };
@@ -142,7 +147,7 @@ describe("createCatalog", () => {
const invalidSpec = { const invalidSpec = {
root: "1", root: "1",
elements: { elements: {
"1": { type: "nonexistent", props: { content: "Hello" } }, "1": { key: "1", type: "nonexistent", props: { content: "Hello" } },
}, },
}; };
@@ -393,6 +398,7 @@ describe("defineCatalog (new schema API)", () => {
root: s.string(), root: s.string(),
elements: s.record( elements: s.record(
s.object({ s.object({
key: s.string(),
type: s.ref("catalog.components"), type: s.ref("catalog.components"),
props: s.any(), props: s.any(),
children: s.array(s.string()), children: s.array(s.string()),
@@ -483,6 +489,7 @@ describe("defineCatalog (new schema API)", () => {
root: "text-1", root: "text-1",
elements: { elements: {
"text-1": { "text-1": {
key: "text-1",
type: "Text", type: "Text",
props: { content: "Hello" }, props: { content: "Hello" },
children: [], children: [],
@@ -551,6 +558,7 @@ describe("defineCatalog (new schema API)", () => {
root: "text-1", root: "text-1",
elements: { elements: {
"text-1": { "text-1": {
key: "text-1",
type: "Text", type: "Text",
props: { content: "Hello" }, props: { content: "Hello" },
children: [], children: [],
+10 -8
View File
@@ -143,9 +143,11 @@ export function createCatalog<
const def = components[componentName]!; const def = components[componentName]!;
return z.object({ return z.object({
key: z.string(),
type: z.literal(componentName as string), type: z.literal(componentName as string),
props: def.props, props: def.props,
children: z.array(z.string()).optional(), children: z.array(z.string()).optional(),
parentKey: z.string().nullable().optional(),
visible: VisibilityConditionSchema.optional(), visible: VisibilityConditionSchema.optional(),
}); });
}); });
@@ -155,9 +157,11 @@ export function createCatalog<
if (componentSchemas.length === 0) { if (componentSchemas.length === 0) {
elementSchema = z.object({ elementSchema = z.object({
key: z.string(),
type: z.string(), type: z.string(),
props: z.record(z.string(), z.unknown()), props: z.record(z.string(), z.unknown()),
children: z.array(z.string()).optional(), children: z.array(z.string()).optional(),
parentKey: z.string().nullable().optional(),
visible: VisibilityConditionSchema.optional(), visible: VisibilityConditionSchema.optional(),
}) as unknown as z.ZodType<UIElement>; }) as unknown as z.ZodType<UIElement>;
} else if (componentSchemas.length === 1) { } else if (componentSchemas.length === 1) {
@@ -493,23 +497,21 @@ export function generateSystemPrompt<
} }
// Output format // Output format
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):"); lines.push("OUTPUT FORMAT (JSONL):");
lines.push('{"op":"add","path":"/root","value":"element-key"}'); lines.push('{"op":"set","path":"/root","value":"element-key"}');
lines.push( lines.push(
'{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}', '{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}',
); );
lines.push('{"op":"remove","path":"/elements/key"}');
lines.push(""); lines.push("");
// Rules // Rules
lines.push("RULES:"); lines.push("RULES:");
const baseRules = [ const baseRules = [
'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}', "First line sets /root to root element key",
'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}', "Add elements with /elements/{key}",
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
"Children array contains string keys, not objects", "Children array contains string keys, not objects",
"Parent first, then children", "Parent first, then children",
"Each element needs: type, props", "Each element needs: key, type, props",
"ONLY use props listed above - never invent new props", "ONLY use props listed above - never invent new props",
]; ];
const allRules = [...baseRules, ...customRules]; const allRules = [...baseRules, ...customRules];
+1 -4
View File
@@ -5,7 +5,6 @@ export type {
DynamicNumber, DynamicNumber,
DynamicBoolean, DynamicBoolean,
UIElement, UIElement,
FlatElement,
Spec, Spec,
VisibilityCondition, VisibilityCondition,
LogicExpression, LogicExpression,
@@ -28,10 +27,8 @@ export {
resolveDynamicValue, resolveDynamicValue,
getByPath, getByPath,
setByPath, setByPath,
addByPath,
removeByPath,
findFormValue, findFormValue,
// SpecStream - streaming format for building specs (RFC 6902) // SpecStream - streaming format for building specs
parseSpecStreamLine, parseSpecStreamLine,
applySpecStreamPatch, applySpecStreamPatch,
compileSpecStream, compileSpecStream,
+9 -8
View File
@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push(""); lines.push("");
lines.push("Example output (each line is a separate JSON object):"); lines.push("Example output (each line is a separate JSON object):");
lines.push(""); lines.push("");
lines.push(`{"op":"add","path":"/root","value":"card-1"} lines.push(`{"op":"set","path":"/root","value":"card-1"}
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}} {"op":"set","path":"/elements/card-1","value":{"key":"card-1","type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"],"parentKey":""}}
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}} {"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":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`); {"op":"set","path":"/elements/chart-1","value":{"key":"chart-1","type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[],"parentKey":"card-1"}}`);
lines.push(""); lines.push("");
// Components section // Components section
@@ -591,11 +591,12 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push("RULES:"); lines.push("RULES:");
const baseRules = [ const baseRules = [
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences", "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}', 'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}', 'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
"ONLY use components listed above", "ONLY use components listed above",
"Each element value needs: type, props, children (array of child keys)", "Each element value needs: key, type, props, children (array of child keys), parentKey",
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')", "Use unique keys (e.g., 'header', 'metric-1', 'chart-revenue')",
"Root element's parentKey is empty string, children reference their parent's key",
]; ];
const allRules = [...baseRules, ...customRules]; const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => { allRules.forEach((rule, i) => {
+1 -435
View File
@@ -1,14 +1,5 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import { resolveDynamicValue, getByPath, setByPath } from "./types";
resolveDynamicValue,
getByPath,
setByPath,
addByPath,
removeByPath,
applySpecStreamPatch,
compileSpecStream,
createSpecStreamCompiler,
} from "./types";
describe("getByPath", () => { describe("getByPath", () => {
it("gets nested values with JSON pointer paths", () => { it("gets nested values with JSON pointer paths", () => {
@@ -132,428 +123,3 @@ describe("resolveDynamicValue", () => {
).toBeUndefined(); ).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 });
});
});
+26 -207
View File
@@ -53,29 +53,18 @@ export interface UIElement<
T extends string = string, T extends string = string,
P = Record<string, unknown>, P = Record<string, unknown>,
> { > {
/** Unique key for reconciliation */
key: string;
/** Component type from the catalog */ /** Component type from the catalog */
type: T; type: T;
/** Component props */ /** Component props */
props: P; props: P;
/** Child element keys (flat structure) */ /** Child element keys (flat structure) */
children?: string[]; children?: string[];
/** 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) */ /** Parent element key (null for root) */
parentKey?: string | null; parentKey?: string | null;
/** Visibility condition */
visible?: VisibilityCondition;
} }
/** /**
@@ -136,20 +125,17 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
export type ValidationMode = "strict" | "warn" | "ignore"; export type ValidationMode = "strict" | "warn" | "ignore";
/** /**
* JSON patch operation types (RFC 6902) * JSON patch operation types
*/ */
export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test"; export type PatchOp = "add" | "remove" | "replace" | "set";
/** /**
* JSON patch operation (RFC 6902) * JSON patch operation
*/ */
export interface JsonPatch { export interface JsonPatch {
op: PatchOp; op: PatchOp;
path: string; path: string;
/** Required for add, replace, test */
value?: unknown; value?: unknown;
/** Required for move, copy (source location) */
from?: string;
} }
/** /**
@@ -171,30 +157,16 @@ export function resolveDynamicValue<T>(
} }
/** /**
* Unescape a JSON Pointer token per RFC 6901 Section 4. * Get a value from an object by JSON Pointer path
* ~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 { export function getByPath(obj: unknown, path: string): unknown {
if (!path || path === "/") { if (!path || path === "/") {
return obj; return obj;
} }
const segments = parseJsonPointer(path); const segments = path.startsWith("/")
? path.slice(1).split("/")
: path.split("/");
let current: unknown = obj; let current: unknown = obj;
@@ -203,10 +175,7 @@ export function getByPath(obj: unknown, path: string): unknown {
return undefined; return undefined;
} }
if (Array.isArray(current)) { if (typeof current === "object") {
const index = parseInt(segment, 10);
current = current[index];
} else if (typeof current === "object") {
current = (current as Record<string, unknown>)[segment]; current = (current as Record<string, unknown>)[segment];
} else { } else {
return undefined; return undefined;
@@ -224,7 +193,7 @@ function isNumericIndex(str: string): boolean {
} }
/** /**
* Set a value in an object by JSON Pointer path (RFC 6901). * Set a value in an object by JSON Pointer path.
* Automatically creates arrays when the path segment is a numeric index. * Automatically creates arrays when the path segment is a numeric index.
*/ */
export function setByPath( export function setByPath(
@@ -232,7 +201,9 @@ export function setByPath(
path: string, path: string,
value: unknown, value: unknown,
): void { ): void {
const segments = parseJsonPointer(path); const segments = path.startsWith("/")
? path.slice(1).split("/")
: path.split("/");
if (segments.length === 0) return; if (segments.length === 0) return;
@@ -242,8 +213,7 @@ export function setByPath(
const segment = segments[i]!; const segment = segments[i]!;
const nextSegment = segments[i + 1]; const nextSegment = segments[i + 1];
const nextIsNumeric = const nextIsNumeric =
nextSegment !== undefined && nextSegment !== undefined && isNumericIndex(nextSegment);
(isNumericIndex(nextSegment) || nextSegment === "-");
if (Array.isArray(current)) { if (Array.isArray(current)) {
const index = parseInt(segment, 10); const index = parseInt(segment, 10);
@@ -259,133 +229,15 @@ export function setByPath(
} }
} }
const lastSegment = segments[segments.length - 1]!;
if (Array.isArray(current)) {
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]!; const lastSegment = segments[segments.length - 1]!;
if (Array.isArray(current)) { if (Array.isArray(current)) {
const index = parseInt(lastSegment, 10); const index = parseInt(lastSegment, 10);
if (index >= 0 && index < current.length) { current[index] = value;
current.splice(index, 1);
}
} else { } else {
delete current[lastSegment]; current[lastSegment] = value;
} }
} }
/**
* 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. * Find a form value from params and/or data.
* Useful in action handlers to locate form input values regardless of path format. * Useful in action handlers to locate form input values regardless of path format.
@@ -482,50 +334,17 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
} }
/** /**
* Apply a single RFC 6902 JSON Patch operation to an object. * Apply a single SpecStream patch to an object.
* Mutates the object in place. * 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>>( export function applySpecStreamPatch<T extends Record<string, unknown>>(
obj: T, obj: T,
patch: SpecStreamLine, patch: SpecStreamLine,
): T { ): T {
switch (patch.op) { if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
case "add": setByPath(obj, patch.path, patch.value);
addByPath(obj, patch.path, patch.value); } else if (patch.op === "remove") {
break; setByPath(obj, patch.path, undefined);
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; return obj;
} }
@@ -535,8 +354,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
* Each line should be a patch operation. * Each line should be a patch operation.
* *
* @example * @example
* const stream = `{"op":"add","path":"/name","value":"Alice"} * const stream = `{"op":"set","path":"/name","value":"Alice"}
* {"op":"add","path":"/age","value":30}`; * {"op":"set","path":"/age","value":30}`;
* const result = compileSpecStream(stream); * const result = compileSpecStream(stream);
* // { name: "Alice", age: 30 } * // { name: "Alice", age: 30 }
*/ */
-25
View File
@@ -1,25 +0,0 @@
# @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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/react", "name": "@json-render/react",
"version": "0.4.4", "version": "0.4.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.", "description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [ "keywords": [
+47 -105
View File
@@ -1,18 +1,8 @@
"use client"; "use client";
import { useState, useCallback, useRef, useEffect } from "react"; import { useState, useCallback, useRef, useEffect } from "react";
import type { import type { Spec, UIElement, JsonPatch } from "@json-render/core";
Spec, import { setByPath } from "@json-render/core";
UIElement,
FlatElement,
JsonPatch,
} from "@json-render/core";
import {
setByPath,
getByPath,
addByPath,
removeByPath,
} from "@json-render/core";
/** /**
* Parse a single JSON patch line * Parse a single JSON patch line
@@ -30,104 +20,56 @@ function parsePatchLine(line: string): JsonPatch | null {
} }
/** /**
* Set a value at a spec path (for add/replace operations). * Apply a JSON patch to the current spec
*/
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 { function applyPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = { ...spec, elements: { ...spec.elements } }; const newSpec = { ...spec, elements: { ...spec.elements } };
switch (patch.op) { switch (patch.op) {
case "set":
case "add": case "add":
case "replace": { case "replace": {
setSpecValue(newSpec, patch.path, patch.value); // 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;
}
}
}
break; break;
} }
case "remove": { case "remove": {
removeSpecValue(newSpec, patch.path); if (patch.path.startsWith("/elements/")) {
break; const elementKey = patch.path.slice("/elements/".length).split("/")[0];
} if (elementKey) {
case "move": { const { [elementKey]: _, ...rest } = newSpec.elements;
if (!patch.from) break; newSpec.elements = rest;
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; break;
} }
} }
@@ -294,18 +236,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: FlatElement[]): Spec { export function flatToTree(
elements: Array<UIElement & { parentKey?: string | null }>,
): Spec {
const elementMap: Record<string, UIElement> = {}; const elementMap: Record<string, UIElement> = {};
let root = ""; let root = "";
// First pass: add all elements to map // First pass: add all elements to map
for (const element of elements) { for (const element of elements) {
elementMap[element.key] = { elementMap[element.key] = {
key: element.key,
type: element.type, type: element.type,
props: element.props, props: element.props,
children: [], children: [],
+4
View File
@@ -15,12 +15,16 @@ export const schema = defineSchema((s) => ({
/** Flat map of elements by key */ /** Flat map of elements by key */
elements: s.record( elements: s.record(
s.object({ s.object({
/** Unique key for this element */
key: s.string(),
/** Component type from catalog */ /** Component type from catalog */
type: s.ref("catalog.components"), type: s.ref("catalog.components"),
/** Component props */ /** Component props */
props: s.propsOf("catalog.components"), props: s.propsOf("catalog.components"),
/** Child element keys (flat reference) */ /** Child element keys (flat reference) */
children: s.array(s.string()), children: s.array(s.string()),
/** Parent element key (null for root) */
parentKey: s.string(),
/** Visibility condition */ /** Visibility condition */
visible: s.any(), visible: s.any(),
}), }),
-25
View File
@@ -1,25 +0,0 @@
# @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", "name": "@json-render/remotion",
"version": "0.4.4", "version": "0.4.1",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.", "description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [ "keywords": [
+9 -10
View File
@@ -25,12 +25,11 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push(""); lines.push("");
lines.push("Example output (each line is a separate JSON object):"); lines.push("Example output (each line is a separate JSON object):");
lines.push(""); lines.push("");
lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}} lines.push(`{"op":"set","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":"set","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":"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":"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":"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":"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":"set","path":"/audio","value":{"tracks":[]}}`);
{"op":"add","path":"/audio","value":{"tracks":[]}}`);
lines.push(""); lines.push("");
// Components // Components
@@ -108,10 +107,10 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("RULES:"); lines.push("RULES:");
const baseRules = [ const baseRules = [
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences", "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}', "First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}', "Then set /tracks array with video/overlay tracks",
'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}', "Then set each clip: /clips/0, /clips/1, etc.",
'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}', "Finally set /audio with {tracks:[]}",
"ONLY use components listed above", "ONLY use components listed above",
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)", "fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)', 'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
+25 -4
View File
@@ -348,11 +348,11 @@ importers:
examples/stripe-app: examples/stripe-app:
dependencies: dependencies:
'@json-render/core': '@json-render/core':
specifier: workspace:* specifier: ^0.4.0
version: link:../../packages/core version: 0.4.0(zod@4.3.5)
'@json-render/react': '@json-render/react':
specifier: workspace:* specifier: ^0.4.0
version: link:../../packages/react version: 0.4.0(react@18.3.1)(zod@4.3.5)
'@stripe/ui-extension-sdk': '@stripe/ui-extension-sdk':
specifier: ^9.1.0 specifier: ^9.1.0
version: 9.1.0(stripe@13.11.0) version: 9.1.0(stripe@13.11.0)
@@ -1793,6 +1793,16 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@json-render/core@0.4.0':
resolution: {integrity: sha512-zcmNNetyXoqShG9qfXnipnkaGLn3XC7Kec/t3+C39mXAStg2RX29ciZwsZT+Fzo900LeRHRIGel7L/IHCdktrA==}
peerDependencies:
zod: ^4.0.0
'@json-render/react@0.4.0':
resolution: {integrity: sha512-OAXdWdOrAXHEFzpEF7xV+84D00JEmLMKlt5u0wc+C/P+q4q6TnpAWx2j28PBpDB2mpidkW9VnTUM+SCH9J8Lrw==}
peerDependencies:
react: ^19.0.0
'@manypkg/find-root@1.1.0': '@manypkg/find-root@1.1.0':
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
@@ -7992,6 +8002,17 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@json-render/core@0.4.0(zod@4.3.5)':
dependencies:
zod: 4.3.5
'@json-render/react@0.4.0(react@18.3.1)(zod@4.3.5)':
dependencies:
'@json-render/core': 0.4.0(zod@4.3.5)
react: 18.3.1
transitivePeerDependencies:
- zod
'@manypkg/find-root@1.1.0': '@manypkg/find-root@1.1.0':
dependencies: dependencies:
'@babel/runtime': 7.28.6 '@babel/runtime': 7.28.6