Compare commits

..
Author SHA1 Message Date
Chris Tate cc4493f9af better registry docs 2026-02-10 13:43:56 -06:00
Chris Tate 5b4fcaa349 chat improvements (#93)
* better chat

* fixes

* fix lint

* chat fixes
2026-02-09 11:15:57 -06:00
Chris Tate 2b3f3a723f better chat (#92)
* better chat

* fixes

* fix lint
2026-02-09 09:44:29 -06:00
github-actions[bot] 726ddc1d4f chore: version packages (#91)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-09 08:43:42 -06:00
Chris Tate 429e456a4f changeset (#90)
* changeset

* update
2026-02-09 08:41:41 -06:00
Chris Tate 0e16ca33d4 Fix LLM hallucinations by dynamically generating prompt examples from catalog (#89)
* smarter schema

* better examples
2026-02-09 08:37:03 -06:00
33 changed files with 1042 additions and 182 deletions
+4
View File
@@ -12,3 +12,7 @@ AI_GATEWAY_MODEL=anthropic/claude-haiku-4.5
# Automatically populated when you add Vercel KV to your project
KV_REST_API_URL=
KV_REST_API_TOKEN=
# Rate Limiting
# RATE_LIMIT_PER_MINUTE=10
# RATE_LIMIT_PER_DAY=100
@@ -121,4 +121,4 @@ AI can reference data paths in component props:
## Next
Learn about [actions](/docs/actions) for user interactions.
Learn about [action handlers](/docs/registry#action-handlers) for user interactions.
-2
View File
@@ -1,7 +1,6 @@
import { DocsMobileNav } from "@/components/docs-mobile-nav";
import { DocsSidebar } from "@/components/docs-sidebar";
import { CopyPageButton } from "@/components/copy-page-button";
import { DocsChat } from "@/components/docs-chat";
export default function DocsLayout({
children,
@@ -25,7 +24,6 @@ export default function DocsLayout({
<article>{children}</article>
</div>
</div>
<DocsChat />
</>
);
}
@@ -165,5 +165,5 @@ export default function Page() {
- Learn about [catalogs](/docs/catalog) in depth
- Explore [data binding](/docs/data-binding) for dynamic values
- Add [actions](/docs/actions) for interactivity
- Add [action handlers](/docs/registry#action-handlers) for interactivity
- Implement [conditional visibility](/docs/visibility)
+69 -16
View File
@@ -2,11 +2,19 @@ export const metadata = { title: "Registry" }
# Registry
Register React components and action handlers to bring your catalog to life.
A registry maps your [catalog](/docs/catalog) definitions to platform-specific implementations. The catalog defines *what* AI can generate — the registry provides the *how*.
## defineRegistry
What a registry contains depends on the schema you use. Each package defines its own schema, which determines the shape of both the catalog and the registry.
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both in a single call:
- **`@json-render/react`** — Components (React elements) and action handlers
- **`@json-render/react-native`** — Components (React Native elements) and action handlers
- **`@json-render/remotion`** — Clip components, transitions, and effects
## @json-render/react
### defineRegistry
Use `defineRegistry` to create a type-safe registry from your catalog. Pass your components, actions, or both:
```tsx
import { defineRegistry } from '@json-render/react';
@@ -49,13 +57,13 @@ export const { registry, handlers, executeAction } = defineRegistry(myCatalog, {
The returned object contains:
- `registry` - component registry for `<Renderer />`
- `handlers` - factory for ActionProvider-compatible handlers
- `executeAction` - imperative action dispatch (for use outside the React tree)
- `registry` — component registry for `<Renderer />`
- `handlers` — factory for ActionProvider-compatible handlers
- `executeAction` — imperative action dispatch (for use outside the React tree)
## Component Props
### Component Props
Each component in the registry receives a `ComponentContext` object:
Each component receives a `ComponentContext` object:
```typescript
interface ComponentContext {
@@ -68,13 +76,11 @@ interface ComponentContext {
Props are automatically inferred from your catalog, so `props.title` is typed as `string` if your catalog defines it that way.
## Action Handlers
### Action Handlers
Instead of AI generating arbitrary code, it declares *intent* by name. Your application provides the implementation. This is a core guardrail.
### Defining Actions
Define available actions in your catalog:
Actions are declared in your [catalog](/docs/catalog). The `@json-render/react` schema supports an `actions` key where you define what operations AI can trigger:
```typescript
import { defineCatalog } from '@json-render/core';
@@ -104,8 +110,6 @@ const catalog = defineCatalog(schema, {
});
```
### Implementing Action Handlers
Action handlers receive `(params, setState, data)` and are defined inside `defineRegistry`:
```tsx
@@ -132,7 +136,7 @@ export const { handlers, executeAction } = defineRegistry(catalog, {
});
```
## Using Data Binding
### Data Binding
Use hooks inside your registry components to read and write data:
@@ -168,7 +172,7 @@ TextField: ({ props }) => {
},
```
## Using the Renderer
### Using the Renderer
Wire everything together with providers and the `<Renderer />` component:
@@ -205,6 +209,55 @@ function App({ spec, data, setState }) {
}
```
## @json-render/react-native
`@json-render/react-native` uses the same `defineRegistry` API. The only difference is that components return React Native elements instead of HTML:
```tsx
import { defineRegistry } from '@json-render/react-native';
import { View, Text, Pressable } from 'react-native';
export const { registry } = defineRegistry(catalog, {
components: {
Card: ({ props, children }) => (
<View style={styles.card}>
<Text style={styles.title}>{props.title}</Text>
{children}
</View>
),
Button: ({ props, emit }) => (
<Pressable onPress={() => emit?.("press")}>
<Text>{props.label}</Text>
</Pressable>
),
},
});
```
See the [@json-render/react-native API reference](/docs/api/react-native) for the full API.
## @json-render/remotion
`@json-render/remotion` takes a different approach. Instead of `defineRegistry`, it uses a plain component registry with built-in standard components for video production:
```tsx
import { Renderer, standardComponents } from '@json-render/remotion';
// Use the standard components directly
<Renderer spec={timelineSpec} components={standardComponents} />
// Or extend with your own
const components = {
...standardComponents,
CustomSlide: ({ clip }) => <AbsoluteFill>{/* ... */}</AbsoluteFill>,
};
```
The Remotion schema also supports `transitions` and `effects` in the catalog rather than actions.
See the [@json-render/remotion API reference](/docs/api/remotion) for the full API.
## Next
Learn about [data binding](/docs/data-binding) for dynamic values.
+3 -3
View File
@@ -18,11 +18,11 @@ GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/remotion, @json-render/codegen
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /docs/ directory.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
When answering questions:
- Use the bash tool to list files (ls /docs/) or search for content (grep -r "keyword" /docs/)
- Use the readFile tool to read specific documentation pages
- Use the bash tool to list files (ls /workspace/docs/) or search for content (grep -r "keyword" /workspace/docs/)
- Use the readFile tool to read specific documentation pages (e.g. readFile with path "/workspace/docs/index.md")
- Always base your answers on the actual documentation content
- Be concise and accurate
- If the docs don't cover a topic, say so honestly
+37 -2
View File
@@ -28,6 +28,7 @@
--border: oklch(0.85 0 0);
--input: oklch(0.85 0 0);
--ring: oklch(0.6 0 0);
--chat-bg: oklch(0.95 0 0);
}
.dark {
@@ -52,6 +53,7 @@
--border: oklch(0.25 0 0);
--input: oklch(0.25 0 0);
--ring: oklch(0.4 0 0);
--chat-bg: oklch(0.25 0 0);
}
@theme inline {
@@ -116,11 +118,11 @@
}
::-webkit-scrollbar-track {
@apply bg-background;
@apply bg-border;
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded;
@apply bg-background rounded;
}
::-webkit-scrollbar-thumb:hover {
@@ -132,6 +134,39 @@ button {
cursor: pointer;
}
/* Tool call shimmer animation */
@keyframes tool-shimmer {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.animate-tool-shimmer {
animation: tool-shimmer 1.5s ease-in-out infinite;
}
/* Dark mode: use page bg color for borders in chat message content */
.dark .docs-chat-content * {
border-color: var(--background);
}
/* Fix list rendering in chat content */
.docs-chat-content ul,
.docs-chat-content ol {
list-style-position: outside;
padding-left: 1.25em;
}
.docs-chat-content li > p {
display: inline;
margin: 0;
}
.docs-chat-content li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
/* Shiki dual theme support */
.shiki,
.shiki span {
+5 -1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { DocsChat } from "@/components/docs-chat";
import { Analytics } from "@vercel/analytics/next";
import { SpeedInsights } from "@vercel/speed-insights/next";
import { PAGE_TITLES } from "@/lib/page-titles";
@@ -79,7 +80,10 @@ export default function RootLayout({
return (
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<ThemeProvider>{children}</ThemeProvider>
<ThemeProvider>
{children}
<DocsChat />
</ThemeProvider>
<Analytics />
<SpeedInsights />
</body>
+334 -102
View File
@@ -4,19 +4,124 @@ import { useRef, useEffect, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { Streamdown } from "streamdown";
import Link from "next/link";
const STORAGE_KEY = "docs-chat-messages";
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
const TOOL_LABELS: Record<
string,
{ label: string; pastLabel: string; argKey?: string }
> = {
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
};
function isToolPart(part: { type: string }): part is {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
} {
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
}
function getToolName(part: { type: string; toolName?: string }): string {
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
return part.type.replace(/^tool-/, "");
}
function ToolCallDisplay({
part,
}: {
part: {
type: string;
toolCallId: string;
toolName?: string;
state: string;
input?: Record<string, unknown>;
output?: unknown;
errorText?: string;
};
}) {
const toolName = getToolName(part);
const config = TOOL_LABELS[toolName] ?? {
label: toolName,
pastLabel: toolName,
};
const isDone = part.state === "output-available";
const isError = part.state === "output-error";
const isRunning = !isDone && !isError;
const displayLabel = isRunning ? config.label : config.pastLabel;
const args = (part.input ?? {}) as Record<string, unknown>;
const argValue = config.argKey ? args[config.argKey] : undefined;
const argPreview =
argValue != null
? String(argValue)
.replace(/^\/workspace\//, "/")
.replace(/\.md$/, "")
.replace(/\/index$/, "")
: "";
// Link to the docs page if it's a /docs/ path from readFile
const docsLink =
toolName === "readFile" &&
(argPreview === "/docs" || argPreview.startsWith("/docs/"))
? argPreview
: null;
const argEl = argPreview ? (
docsLink ? (
<Link href={docsLink} className="truncate underline underline-offset-2">
{argPreview}
</Link>
) : (
<span className="truncate">{argPreview}</span>
)
) : null;
return (
<div className="text-xs py-0.5 min-w-0">
{isRunning ? (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground animate-tool-shimmer min-w-0">
<span className="shrink-0">{displayLabel}</span>
{argEl}
</span>
) : (
<span className="inline-flex items-center gap-1 font-mono text-muted-foreground/60 min-w-0">
<span className="shrink-0">{displayLabel}</span>
{argEl}
{isError && <span className="text-destructive">failed</span>}
</span>
)}
</div>
);
}
const SUGGESTIONS = [
"What is json-render?",
"How do I install it?",
"How does streaming work?",
"What components are available?",
"How do I create a custom schema?",
];
export function DocsChat() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [focused, setFocused] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const restoredRef = useRef(false);
const { messages, sendMessage, status, setMessages } = useChat({ transport });
const { messages, sendMessage, status, setMessages, error } = useChat({
transport,
});
const isLoading = status === "streaming" || status === "submitted";
@@ -52,19 +157,48 @@ export function DocsChat() {
}
}, [messages, isLoading]);
// Auto-open when new messages arrive
const prevMessageCount = useRef(0);
// Auto-open when new messages arrive (but not on initial restore)
const prevMessageCount = useRef<number | null>(null);
const initializedRef = useRef(false);
useEffect(() => {
if (messages.length > prevMessageCount.current) {
// Skip until after the first sessionStorage restore cycle
if (!initializedRef.current) {
// Wait one tick after mount to let restore settle
const id = requestAnimationFrame(() => {
prevMessageCount.current = messages.length;
initializedRef.current = true;
});
return () => cancelAnimationFrame(id);
}
if (
prevMessageCount.current !== null &&
messages.length > prevMessageCount.current
) {
setOpen(true);
}
prevMessageCount.current = messages.length;
}, [messages.length]);
// Scroll to bottom when messages change
// Scroll to bottom when messages change or error occurs
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
}, [messages, error]);
// Cmd+K to focus prompt, Esc to close
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
inputRef.current?.focus();
}
if (e.key === "Escape" && open) {
setOpen(false);
inputRef.current?.blur();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open]);
// Close message area when clicking outside
useEffect(() => {
@@ -95,113 +229,211 @@ export function DocsChat() {
inputRef.current?.focus();
};
const getTextFromParts = (
const hasVisibleContent = (
parts: (typeof messages)[number]["parts"],
): string => {
return parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
)
.map((p) => p.text)
.join("");
): boolean => {
return parts.some(
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
);
};
// Auto-open when error occurs
useEffect(() => {
if (error) setOpen(true);
}, [error]);
const showMessages = open && (messages.length > 0 || !!error || isLoading);
const showSuggestions = focused && messages.length === 0 && !isLoading;
return (
<div className="fixed bottom-0 left-0 right-0 z-50 pointer-events-none">
<div
ref={containerRef}
className="max-w-xl mx-auto px-4 pb-4 [&>*]:pointer-events-auto"
className={`mx-auto px-4 pb-4 [&>*]:pointer-events-auto transition-all duration-300 ${focused || showMessages ? "max-w-xl" : "max-w-56"}`}
>
{/* Messages panel */}
{open && messages.length > 0 && (
<div className="mb-2 bg-background border border-border rounded-lg shadow-lg max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-border shrink-0">
<span className="text-xs font-medium text-muted-foreground">
json-render Docs
</span>
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
</div>
<div className="p-4 space-y-4 overflow-y-auto">
{messages.map((message) => {
const text = getTextFromParts(message.parts);
if (!text) return null;
return (
<div key={message.id}>
{message.role === "assistant" ? (
<div className="text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{text}</Streamdown>
</div>
) : (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{text}
</div>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()}
className="flex items-end gap-2 bg-background border border-border rounded-lg shadow-lg px-4 py-3 cursor-text"
<div
className={`border rounded-lg overflow-hidden ${focused || showMessages ? "border-background" : "border-[var(--chat-bg)]"}`}
style={{ backgroundColor: "var(--chat-bg)" }}
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
placeholder="Ask about the docs..."
rows={1}
onFocus={() => {
if (messages.length > 0) setOpen(true);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className="bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30"
aria-label="Send message"
{/* Suggestions panel */}
{showSuggestions && (
<div>
<div className="flex items-center px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
json-render Docs
</span>
</div>
<div className="flex flex-wrap gap-2 p-3">
{SUGGESTIONS.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => {
e.preventDefault();
setOpen(true);
sendMessage({ text: s });
}}
className="text-xs px-3 py-1.5 rounded-full border border-background bg-background font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{s}
</button>
))}
</div>
</div>
)}
{/* Messages panel */}
{showMessages && (
<div className="max-h-[60vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-2 border-b border-background shrink-0">
<span className="text-xs font-medium text-muted-foreground">
json-render Docs
</span>
<button
onClick={handleClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear conversation"
>
Clear
</button>
</div>
<div
className="p-4 space-y-4 overflow-y-auto"
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) {
setOpen(false);
}
}}
>
{messages.map((message) => {
if (!hasVisibleContent(message.parts)) return null;
return (
<div key={message.id}>
{message.role === "user" ? (
<div className="text-sm text-muted-foreground whitespace-pre-wrap leading-relaxed">
{message.parts
.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
)
.map((p) => p.text)
.join("")}
</div>
) : (
<div className="space-y-2">
{message.parts.map((part, i) => {
if (part.type === "text" && part.text) {
return (
<div
key={i}
className="docs-chat-content text-sm text-foreground/90 leading-relaxed prose prose-sm dark:prose-invert max-w-none"
>
<Streamdown>{part.text}</Streamdown>
</div>
);
}
if (isToolPart(part)) {
return (
<ToolCallDisplay
key={part.toolCallId}
part={part}
/>
);
}
return null;
})}
</div>
)}
</div>
);
})}
{error && (
<div className="text-sm text-destructive/80 bg-destructive/10 rounded-md px-3 py-2">
{(() => {
try {
const parsed = JSON.parse(error.message);
return parsed.message || parsed.error || error.message;
} catch {
return (
error.message ||
"Something went wrong. Please try again."
);
}
})()}
</div>
)}
<div ref={messagesEndRef} />
</div>
</div>
)}
{/* Input bar */}
<form
onSubmit={handleSubmit}
onClick={() => inputRef.current?.focus()}
className={`relative flex items-end gap-2 px-3 py-2 cursor-text${showMessages ? " border-t border-background" : ""}`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{!input && (
<div className="absolute inset-0 flex items-center px-3 pointer-events-none">
<span className="text-sm text-muted-foreground truncate flex-1">
Ask a question...
</span>
{!focused && !showMessages && (
<span className="text-muted-foreground/40 font-mono text-xs shrink-0">
&#8984;K
</span>
)}
</div>
)}
<textarea
ref={inputRef}
value={input}
onChange={(e) => {
setInput(e.target.value);
e.target.style.height = "auto";
e.target.style.height = `${e.target.scrollHeight}px`;
}}
rows={1}
onFocus={() => {
setFocused(true);
if (messages.length > 0) setOpen(true);
}}
onBlur={() => {
setFocused(false);
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
inputRef.current?.blur();
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
className="flex-1 bg-transparent text-base sm:text-sm text-foreground outline-none disabled:opacity-50 resize-none max-h-32 leading-relaxed relative z-10"
/>
<button
type="submit"
disabled={isLoading || !input.trim()}
className={`bg-primary text-primary-foreground rounded-md p-1 hover:bg-primary/90 transition-colors disabled:opacity-30${!focused && !showMessages ? " hidden" : ""}`}
aria-label="Send message"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</form>
</div>
</div>
</div>
);
+7 -4
View File
@@ -21,7 +21,10 @@ const noopRateLimiter = {
limit: async () => ({ success: true, limit: 0, remaining: 0, reset: 0 }),
};
// 10 requests per minute (sliding window)
const MINUTE_LIMIT = Number(process.env.RATE_LIMIT_PER_MINUTE) || 10;
const DAILY_LIMIT = Number(process.env.RATE_LIMIT_PER_DAY) || 100;
// Requests per minute (sliding window)
export const minuteRateLimit = {
limit: async (identifier: string) => {
if (!_minuteRateLimit) {
@@ -29,7 +32,7 @@ export const minuteRateLimit = {
if (!redis) return noopRateLimiter.limit();
_minuteRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(10, "1 m"),
limiter: Ratelimit.slidingWindow(MINUTE_LIMIT, "1 m"),
prefix: "ratelimit:minute",
});
}
@@ -37,7 +40,7 @@ export const minuteRateLimit = {
},
};
// 100 requests per day (fixed window)
// Requests per day (fixed window)
export const dailyRateLimit = {
limit: async (identifier: string) => {
if (!_dailyRateLimit) {
@@ -45,7 +48,7 @@ export const dailyRateLimit = {
if (!redis) return noopRateLimiter.limit();
_dailyRateLimit = new Ratelimit({
redis,
limiter: Ratelimit.fixedWindow(100, "1 d"),
limiter: Ratelimit.fixedWindow(DAILY_LIMIT, "1 d"),
prefix: "ratelimit:daily",
});
}
+27
View File
@@ -24,6 +24,7 @@ export const playgroundCatalog = defineCatalog(schema, {
slots: ["default"],
description:
"Container card for content sections. Use for forms/content boxes, NOT for page headers.",
example: { title: "Overview", description: "Your account summary" },
},
Stack: {
@@ -37,6 +38,7 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
slots: ["default"],
description: "Flex container for layouts",
example: { direction: "vertical", gap: "md" },
},
Grid: {
@@ -46,6 +48,7 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
slots: ["default"],
description: "Grid layout (1-6 columns)",
example: { columns: 3, gap: "md" },
},
Separator: {
@@ -137,6 +140,13 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
description:
'Data table. columns: header labels. rows: 2D array of cell strings, e.g. [["Alice","admin"],["Bob","user"]].',
example: {
columns: ["Name", "Role"],
rows: [
["Alice", "Admin"],
["Bob", "User"],
],
},
},
Heading: {
@@ -145,6 +155,7 @@ export const playgroundCatalog = defineCatalog(schema, {
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Heading text (h1-h4)",
example: { text: "Welcome", level: "h1" },
},
Text: {
@@ -155,6 +166,7 @@ export const playgroundCatalog = defineCatalog(schema, {
.nullable(),
}),
description: "Paragraph text",
example: { text: "Hello, world!" },
},
Image: {
@@ -173,6 +185,7 @@ export const playgroundCatalog = defineCatalog(schema, {
size: z.enum(["sm", "md", "lg"]).nullable(),
}),
description: "User avatar with fallback initials",
example: { name: "Jane Doe", size: "md" },
},
Badge: {
@@ -181,6 +194,7 @@ export const playgroundCatalog = defineCatalog(schema, {
variant: z.enum(["default", "success", "warning", "danger"]).nullable(),
}),
description: "Status badge",
example: { text: "Active", variant: "success" },
},
Alert: {
@@ -190,6 +204,11 @@ export const playgroundCatalog = defineCatalog(schema, {
type: z.enum(["info", "success", "warning", "error"]).nullable(),
}),
description: "Alert banner",
example: {
title: "Note",
message: "Your changes have been saved.",
type: "success",
},
},
Progress: {
@@ -199,6 +218,7 @@ export const playgroundCatalog = defineCatalog(schema, {
label: z.string().nullable(),
}),
description: "Progress bar (value 0-100)",
example: { value: 65, max: 100, label: "Upload progress" },
},
Skeleton: {
@@ -281,6 +301,12 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
events: ["submit", "focus", "blur"],
description: "Text input field. Use statePath for two-way binding.",
example: {
label: "Email",
name: "email",
type: "email",
placeholder: "you@example.com",
},
},
Textarea: {
@@ -360,6 +386,7 @@ export const playgroundCatalog = defineCatalog(schema, {
}),
events: ["press"],
description: "Clickable button. Bind on.press for handler.",
example: { label: "Submit", variant: "primary" },
},
Link: {
+5
View File
@@ -11,6 +11,11 @@ const nextConfig = {
destination: "/docs/registry",
permanent: true,
},
{
source: "/docs/actions",
destination: "/docs/registry#action-handlers",
permanent: true,
},
];
},
};
+18
View File
@@ -18,6 +18,7 @@ export const dashboardCatalog = defineCatalog(schema, {
}),
slots: ["default"],
description: "Flex layout container",
example: { direction: "vertical", gap: "md" },
},
Accordion: {
@@ -50,6 +51,7 @@ export const dashboardCatalog = defineCatalog(schema, {
}),
description:
"Clickable button. Use actionParams to pass parameters to the action (e.g., { limit: 5, sort: 'newest' })",
example: { label: "Save", variant: "default", action: "formSubmit" },
},
Input: {
@@ -60,6 +62,12 @@ export const dashboardCatalog = defineCatalog(schema, {
type: z.enum(["text", "email", "password", "number", "tel"]).nullable(),
}),
description: "Text input field",
example: {
label: "Email",
valuePath: "/form/email",
placeholder: "you@example.com",
type: "email",
},
},
Form: {
@@ -81,6 +89,7 @@ export const dashboardCatalog = defineCatalog(schema, {
.nullable(),
}),
description: "Status badge",
example: { text: "Active", variant: "default" },
},
Alert: {
@@ -304,6 +313,13 @@ export const dashboardCatalog = defineCatalog(schema, {
emptyMessage: z.string().nullable(),
}),
description: "Data table with optional row actions (delete, edit, etc.)",
example: {
statePath: "/customers/data",
columns: [
{ key: "name", label: "Name" },
{ key: "email", label: "Email" },
],
},
},
// Typography
@@ -313,6 +329,7 @@ export const dashboardCatalog = defineCatalog(schema, {
level: z.enum(["h1", "h2", "h3", "h4"]).nullable(),
}),
description: "Section heading",
example: { text: "Dashboard", level: "h1" },
},
Text: {
@@ -321,6 +338,7 @@ export const dashboardCatalog = defineCatalog(schema, {
muted: z.boolean().nullable(),
}),
description: "Text content",
example: { content: "Welcome back! Here is your overview." },
},
// Charts
@@ -47,6 +47,7 @@ export const catalog = defineCatalog(schema, {
slots: [],
description:
"Icon display using Ionicons. Use for action buttons, navigation items, and indicators. ALWAYS use this instead of emoji characters for UI icons. Use Ionicons naming convention (e.g. heart, heart-outline, chatbubble-outline, share-social-outline).",
example: { name: "heart-outline", size: 24, color: "#007AFF" },
},
},
actions: standardActionDefinitions,
@@ -26,6 +26,7 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Flex layout container for arranging children horizontally or vertically with configurable gap and alignment",
example: { direction: "vertical", gap: "medium" },
},
Inline: {
@@ -71,6 +72,7 @@ export const stripeCatalog = defineCatalog(schema, {
.default("large"),
}),
description: "Display a heading/title text with configurable size",
example: { text: "Overview", size: "large" },
},
Text: {
@@ -94,6 +96,7 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Display body text with configurable color, size, and weight",
example: { content: "Payment received successfully.", color: "primary" },
},
// =========================================================================
@@ -111,6 +114,13 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Display a key metric with label, value, and optional trend indicator for KPIs",
example: {
label: "Revenue",
value: "$12,450",
change: "+8.2%",
changeType: "positive",
format: "currency",
},
},
Badge: {
@@ -128,6 +138,7 @@ export const stripeCatalog = defineCatalog(schema, {
.default("neutral"),
}),
description: "Status badge indicator with configurable color type",
example: { label: "Active", type: "positive" },
},
Icon: {
@@ -284,6 +295,12 @@ export const stripeCatalog = defineCatalog(schema, {
required: z.boolean().nullable(),
}),
description: "Text input field with label, validation, and data binding",
example: {
label: "Email",
placeholder: "customer@example.com",
valuePath: "/form/email",
type: "email",
},
},
TextArea: {
@@ -379,6 +396,11 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Action button with configurable style, size, and action handling",
example: {
label: "View Details",
action: "viewCustomer",
type: "primary",
},
},
ButtonGroup: {
@@ -451,6 +473,14 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Data table with configurable columns and optional row actions",
example: {
title: "Recent Payments",
statePath: "/payments/data",
columns: [
{ key: "amount", label: "Amount" },
{ key: "status", label: "Status" },
],
},
},
// =========================================================================
@@ -465,6 +495,11 @@ export const stripeCatalog = defineCatalog(schema, {
}),
description:
"Card displaying customer information with name, email, and status",
example: {
name: "Jane Smith",
email: "jane@example.com",
status: "active",
},
},
PaymentCard: {
+8
View File
@@ -1,5 +1,13 @@
# @json-render/codegen
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.5.1",
"version": "0.5.2",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+6
View File
@@ -1,5 +1,11 @@
# @json-render/core
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
## 0.5.1
## 0.5.0
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.5.1",
"version": "0.5.2",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+154
View File
@@ -403,6 +403,7 @@ describe("defineCatalog (new schema API)", () => {
components: s.map({
props: s.zod(),
description: s.string(),
example: s.any(),
}),
actions: s.map({
description: s.string(),
@@ -560,4 +561,157 @@ describe("defineCatalog (new schema API)", () => {
expect(result.success).toBe(true);
});
it("does not include hardcoded component names in prompt (issue #88)", () => {
// When a catalog only has "Text", the generated prompt should NOT
// reference components like Stack, Grid, Heading, Card, Column,
// Button, or Pressable that are not in the catalog.
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({ content: z.string() }),
description: "Display text content",
},
},
actions: {},
});
const prompt = catalog.prompt();
// The prompt should contain the actual catalog component
expect(prompt).toContain("Text");
expect(prompt).toContain("Display text content");
// The prompt should NOT contain hardcoded component names not in the catalog
// Check that these don't appear as component types in JSON examples
const hardcodedComponents = [
"Stack",
"Grid",
"Heading",
"Card",
"Column",
"Pressable",
];
for (const comp of hardcodedComponents) {
// Check for "type":"<ComponentName>" patterns in JSON examples
expect(prompt).not.toContain(`"type":"${comp}"`);
// Also check for "type": "<ComponentName>" with space
expect(prompt).not.toContain(`"type": "${comp}"`);
}
});
it("uses actual catalog component names in prompt examples", () => {
const catalog = defineCatalog(testSchema, {
components: {
MyBox: {
props: z.object({ padding: z.number() }),
description: "A box",
},
MyLabel: {
props: z.object({ text: z.string() }),
description: "A label",
},
},
actions: {},
});
const prompt = catalog.prompt();
// The example output should use MyBox and MyLabel, not hardcoded names
expect(prompt).toContain('"type":"MyBox"');
expect(prompt).toContain('"type":"MyLabel"');
// Should not contain any hardcoded component names in type fields
expect(prompt).not.toContain('"type":"Stack"');
expect(prompt).not.toContain('"type":"Grid"');
expect(prompt).not.toContain('"type":"Heading"');
expect(prompt).not.toContain('"type":"Column"');
expect(prompt).not.toContain('"type":"Button"');
expect(prompt).not.toContain('"type":"Pressable"');
});
it("generates example props from Zod schema when no example provided", () => {
const catalog = defineCatalog(testSchema, {
components: {
Text: {
props: z.object({
content: z.string(),
size: z.number(),
bold: z.boolean(),
variant: z.enum(["body", "heading"]),
color: z.string().optional(),
}),
description: "Display text",
},
},
actions: {},
});
const prompt = catalog.prompt();
// Required props should appear with generated values in examples
// (string -> "example", number -> 0, boolean -> true, enum -> first value)
expect(prompt).toContain('"content":"example"');
expect(prompt).toContain('"size":0');
expect(prompt).toContain('"bold":true');
expect(prompt).toContain('"variant":"body"');
// Optional props should NOT appear in examples (keeps them concise)
// The prop name "color" should still appear in the AVAILABLE COMPONENTS
// section but not in the JSON example objects
const exampleSection = prompt.split("AVAILABLE COMPONENTS")[0]!;
expect(exampleSection).not.toContain('"color"');
// Prompt examples should never have empty props:{}
expect(exampleSection).not.toContain('"props":{}');
});
it("uses explicit example field over Zod-generated values", () => {
const catalog = defineCatalog(testSchema, {
components: {
Heading: {
props: z.object({
text: z.string(),
level: z.enum(["h1", "h2", "h3"]),
}),
description: "A heading",
example: { text: "Welcome to My App", level: "h1" },
},
Paragraph: {
props: z.object({ content: z.string() }),
description: "A paragraph",
example: { content: "Lorem ipsum dolor sit amet" },
},
},
actions: {},
});
const prompt = catalog.prompt();
// Should use the explicit example values, not "example" or first enum value
expect(prompt).toContain('"text":"Welcome to My App"');
expect(prompt).toContain('"level":"h1"');
expect(prompt).toContain('"content":"Lorem ipsum dolor sit amet"');
});
it("uses $path binding on first string prop for repeat example", () => {
const catalog = defineCatalog(testSchema, {
components: {
Card: {
props: z.object({
title: z.string(),
subtitle: z.string(),
}),
description: "A card",
},
},
actions: {},
});
const prompt = catalog.prompt();
// In the repeat/item example, the first string prop should get a $path binding
expect(prompt).toContain('"title":{"$path":"$item/title"}');
});
});
+2
View File
@@ -22,6 +22,8 @@ export interface ComponentDefinition<
hasChildren?: boolean;
/** Description for AI generation */
description?: string;
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example?: Record<string, unknown>;
}
/**
+240 -30
View File
@@ -540,27 +540,84 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
lines.push("");
// Output format section - explain JSONL streaming patch format
lines.push("OUTPUT FORMAT:");
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
lines.push(
"Output JSONL (one JSON object per line) with patches to build a UI tree.",
"Output JSONL (one JSON object per line) using RFC 6902 JSON Patch operations to build a UI tree.",
);
lines.push(
"Each line is a JSON patch operation. Start with /root, then stream /elements and /state patches interleaved so the UI fills in progressively as it streams.",
"Each line is a JSON patch operation (add, remove, replace). Start with /root, then stream /elements and /state patches interleaved so the UI fills in progressively as it streams.",
);
lines.push("");
lines.push("Example output (each line is a separate JSON object):");
lines.push("");
lines.push(`{"op":"add","path":"/root","value":"blog"}
{"op":"add","path":"/elements/blog","value":{"type":"Stack","props":{"direction":"vertical","gap":"md"},"children":["heading","posts-grid"]}}
{"op":"add","path":"/elements/heading","value":{"type":"Heading","props":{"text":"Blog","level":"h1"},"children":[]}}
{"op":"add","path":"/elements/posts-grid","value":{"type":"Grid","props":{"columns":2,"gap":"md"},"repeat":{"path":"/posts","key":"id"},"children":["post-card"]}}
{"op":"add","path":"/elements/post-card","value":{"type":"Card","props":{"title":{"$path":"$item/title"}},"children":["post-meta"]}}
{"op":"add","path":"/elements/post-meta","value":{"type":"Text","props":{"text":{"$path":"$item/author"},"variant":"muted"},"children":[]}}
{"op":"add","path":"/state/posts","value":[]}
{"op":"add","path":"/state/posts/0","value":{"id":"1","title":"Getting Started","author":"Jane","date":"Jan 15"}}
{"op":"add","path":"/state/posts/1","value":{"id":"2","title":"Advanced Tips","author":"Bob","date":"Feb 3"}}
Note: state patches appear right after the elements that use them, so the UI fills in as it streams.`);
// Build example using actual catalog component names and props to avoid hallucinations
const allComponents = (catalog.data as Record<string, unknown>).components as
| Record<string, CatalogComponentDef>
| undefined;
const cn = catalog.componentNames;
const comp1 = cn[0] || "Component";
const comp2 = cn.length > 1 ? cn[1]! : comp1;
const comp1Def = allComponents?.[comp1];
const comp2Def = allComponents?.[comp2];
const comp1Props = comp1Def ? getExampleProps(comp1Def) : {};
const comp2Props = comp2Def ? getExampleProps(comp2Def) : {};
// Find a string prop on comp2 to demonstrate $path dynamic bindings
const dynamicPropName = comp2Def?.props
? findFirstStringProp(comp2Def.props)
: null;
const dynamicProps = dynamicPropName
? { ...comp2Props, [dynamicPropName]: { $path: "$item/title" } }
: comp2Props;
const exampleOutput = [
JSON.stringify({ op: "add", path: "/root", value: "main" }),
JSON.stringify({
op: "add",
path: "/elements/main",
value: {
type: comp1,
props: comp1Props,
children: ["child-1", "list"],
},
}),
JSON.stringify({
op: "add",
path: "/elements/child-1",
value: { type: comp2, props: comp2Props, children: [] },
}),
JSON.stringify({
op: "add",
path: "/elements/list",
value: {
type: comp1,
props: comp1Props,
repeat: { path: "/items", key: "id" },
children: ["item"],
},
}),
JSON.stringify({
op: "add",
path: "/elements/item",
value: { type: comp2, props: dynamicProps, children: [] },
}),
JSON.stringify({ op: "add", path: "/state/items", value: [] }),
JSON.stringify({
op: "add",
path: "/state/items/0",
value: { id: "1", title: "First Item" },
}),
JSON.stringify({
op: "add",
path: "/state/items/1",
value: { id: "2", title: "Second Item" },
}),
].join("\n");
lines.push(`${exampleOutput}
Note: state patches appear right after the elements that use them, so the UI fills in as it streams. ONLY use component types from the AVAILABLE COMPONENTS list below.`);
lines.push("");
// Initial state section
@@ -601,7 +658,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
'The element itself renders once (as the container), and its children are expanded once per array item. "path" is the state array path. "key" is an optional field name on each item for stable React keys.',
);
lines.push(
'Example: { "type": "Column", "props": { "gap": 8 }, "repeat": { "path": "/todos", "key": "id" }, "children": ["todo-item"] }',
`Example: ${JSON.stringify({ type: comp1, props: comp1Props, repeat: { path: "/todos", key: "id" }, children: ["todo-item"] })}`,
);
lines.push(
'Inside children of a repeated element, use "$item/field" for per-item paths: statePath:"$item/completed", { "$path": "$item/title" }. Use "$index" for the current array index.',
@@ -638,18 +695,8 @@ Note: state patches appear right after the elements that use them, so the UI fil
);
lines.push("");
// Components section
const components = (catalog.data as Record<string, unknown>).components as
| Record<
string,
{
props?: z.ZodType;
description?: string;
slots?: string[];
events?: string[];
}
>
| undefined;
// Components section — reuse the typed reference from example generation
const components = allComponents;
if (components) {
lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
@@ -694,7 +741,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push("");
lines.push("Example:");
lines.push(
' {"type":"Button","props":{"label":"Save"},"on":{"press":{"action":"setState","params":{"path":"/saved","value":true}}},"children":[]}',
` ${JSON.stringify({ type: comp1, props: comp1Props, on: { press: { action: "setState", params: { path: "/saved", value: true } } }, children: [] })}`,
);
lines.push("");
lines.push(
@@ -711,7 +758,7 @@ Note: state patches appear right after the elements that use them, so the UI fil
"Elements can have an optional `visible` field to conditionally show/hide based on data state. IMPORTANT: `visible` is a top-level field on the element object (sibling of type/props/children), NOT inside props.",
);
lines.push(
'Correct: {"type":"Column","props":{"gap":8},"visible":{"eq":[{"path":"/tab"},"home"]},"children":[...]}',
`Correct: ${JSON.stringify({ type: comp1, props: comp1Props, visible: { eq: [{ path: "/tab" }, "home"] }, children: ["..."] })}`,
);
lines.push(
'- `{ "eq": [{ "path": "/statePath" }, "value"] }` - visible when state at path equals value',
@@ -726,10 +773,10 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push("- `true` / `false` - always visible/hidden");
lines.push("");
lines.push(
"Use the Pressable component with on.press bound to setState to update state and drive visibility.",
"Use a component with on.press bound to setState to update state and drive visibility.",
);
lines.push(
'Example: A Pressable with on: { "press": { "action": "setState", "params": { "path": "/activeTab", "value": "home" } } } sets state, then a container with visible: { "eq": [{ "path": "/activeTab" }, "home"] } shows only when that tab is active.',
`Example: A ${comp1} with on: { "press": { "action": "setState", "params": { "path": "/activeTab", "value": "home" } } } sets state, then a container with visible: { "eq": [{ "path": "/activeTab" }, "home"] } shows only when that tab is active.`,
);
lines.push("");
@@ -781,6 +828,169 @@ Note: state patches appear right after the elements that use them, so the UI fil
return lines.join("\n");
}
// =============================================================================
// Example Value Generation from Zod Schemas
// =============================================================================
/**
* Component definition shape as it appears in catalog data
*/
interface CatalogComponentDef {
props?: z.ZodType;
description?: string;
slots?: string[];
events?: string[];
example?: Record<string, unknown>;
}
/**
* Get example props for a catalog component.
* Uses the explicit `example` field if provided, otherwise generates from Zod schema.
*/
function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
if (def.example && Object.keys(def.example).length > 0) {
return def.example;
}
if (def.props) {
return generateExamplePropsFromZod(def.props);
}
return {};
}
/**
* Generate example prop values from a Zod object schema.
* Only includes required fields to keep examples concise.
*/
function generateExamplePropsFromZod(
schema: z.ZodType,
): Record<string, unknown> {
if (!schema || !schema._def) return {};
const def = schema._def as unknown as Record<string, unknown>;
const typeName = getZodTypeName(schema);
if (typeName !== "ZodObject" && typeName !== "object") return {};
const shape =
typeof def.shape === "function"
? (def.shape as () => Record<string, z.ZodType>)()
: (def.shape as Record<string, z.ZodType>);
if (!shape) return {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(shape)) {
const innerTypeName = getZodTypeName(value);
// Skip optional props to keep examples concise
if (
innerTypeName === "ZodOptional" ||
innerTypeName === "optional" ||
innerTypeName === "ZodNullable" ||
innerTypeName === "nullable"
) {
continue;
}
result[key] = generateExampleValue(value);
}
return result;
}
/**
* Generate a single example value from a Zod type.
*/
function generateExampleValue(schema: z.ZodType): unknown {
if (!schema || !schema._def) return "...";
const def = schema._def as unknown as Record<string, unknown>;
const typeName = getZodTypeName(schema);
switch (typeName) {
case "ZodString":
case "string":
return "example";
case "ZodNumber":
case "number":
return 0;
case "ZodBoolean":
case "boolean":
return true;
case "ZodLiteral":
case "literal":
return def.value;
case "ZodEnum":
case "enum": {
if (Array.isArray(def.values) && def.values.length > 0)
return def.values[0];
if (def.entries && typeof def.entries === "object") {
const values = Object.values(def.entries as Record<string, string>);
return values.length > 0 ? values[0] : "example";
}
return "example";
}
case "ZodOptional":
case "optional":
case "ZodNullable":
case "nullable":
case "ZodDefault":
case "default": {
const inner = (def.innerType as z.ZodType) ?? (def.wrapped as z.ZodType);
return inner ? generateExampleValue(inner) : null;
}
case "ZodArray":
case "array":
return [];
case "ZodObject":
case "object":
return generateExamplePropsFromZod(schema);
case "ZodUnion":
case "union": {
const options = def.options as z.ZodType[] | undefined;
return options && options.length > 0
? generateExampleValue(options[0]!)
: "...";
}
default:
return "...";
}
}
/**
* Find the name of the first required string prop in a Zod object schema.
* Used to demonstrate $path dynamic bindings in examples.
*/
function findFirstStringProp(schema?: z.ZodType): string | null {
if (!schema || !schema._def) return null;
const def = schema._def as unknown as Record<string, unknown>;
const typeName = getZodTypeName(schema);
if (typeName !== "ZodObject" && typeName !== "object") return null;
const shape =
typeof def.shape === "function"
? (def.shape as () => Record<string, z.ZodType>)()
: (def.shape as Record<string, z.ZodType>);
if (!shape) return null;
for (const [key, value] of Object.entries(shape)) {
const innerTypeName = getZodTypeName(value);
// Skip optional props
if (
innerTypeName === "ZodOptional" ||
innerTypeName === "optional" ||
innerTypeName === "ZodNullable" ||
innerTypeName === "nullable"
) {
continue;
}
// Unwrap to check the actual type
if (innerTypeName === "ZodString" || innerTypeName === "string") {
return key;
}
}
return null;
}
// =============================================================================
// Zod Introspection Helpers
// =============================================================================
/**
* Get Zod type name from schema (handles different Zod versions)
*/
+8
View File
@@ -1,5 +1,13 @@
# @json-render/react-native
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.5.1",
"version": "0.5.2",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+26
View File
@@ -28,6 +28,7 @@ export const standardComponentDefinitions = {
slots: ["default"],
description:
"Generic container wrapper. Use for grouping elements with padding, margin, and background color.",
example: { padding: 16, backgroundColor: "#FFFFFF" },
},
Row: {
@@ -53,6 +54,7 @@ export const standardComponentDefinitions = {
slots: ["default"],
description:
"Horizontal flex layout. Use for placing elements side by side.",
example: { gap: 12, alignItems: "center" },
},
Column: {
@@ -77,6 +79,7 @@ export const standardComponentDefinitions = {
slots: ["default"],
description:
"Vertical flex layout. Use for stacking elements top to bottom.",
example: { gap: 12, padding: 16 },
},
ScrollContainer: {
@@ -143,6 +146,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Heading text at various levels. h1 is largest, h4 is smallest.",
example: { text: "Welcome", level: "h1" },
},
Paragraph: {
@@ -156,6 +160,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Body text paragraph. Use for descriptions and longer content.",
example: { text: "This is a paragraph of body text." },
},
Label: {
@@ -168,6 +173,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Small utility text. Use for captions, form labels, and secondary information.",
example: { text: "Status", size: "sm" },
},
Image: {
@@ -181,6 +187,12 @@ export const standardComponentDefinitions = {
}),
slots: [],
description: "Image display. Provide a source URL and optional dimensions.",
example: {
src: "https://picsum.photos/300/200",
alt: "Photo",
width: 300,
height: 200,
},
},
Avatar: {
@@ -193,6 +205,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Circular avatar showing an image or initials. Use for user profiles and contacts.",
example: { initials: "JD", size: "md" },
},
Badge: {
@@ -205,6 +218,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Small colored indicator with a label. Use for status, counts, and categories.",
example: { label: "New", variant: "info" },
},
Chip: {
@@ -237,6 +251,7 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Pressable button with label. Set variant for styling. Bind on.press for the handler to call on press.",
example: { label: "Submit", variant: "primary" },
},
TextInput: {
@@ -257,6 +272,11 @@ export const standardComponentDefinitions = {
slots: [],
description:
"Text input field. Use statePath to bind to the state model for two-way binding. The value typed by the user is stored at the statePath.",
example: {
placeholder: "Enter text...",
statePath: "/inputValue",
label: "Name",
},
},
Switch: {
@@ -351,6 +371,7 @@ export const standardComponentDefinitions = {
slots: ["default"],
description:
"Elevated card container with optional title. Use for grouping related content.",
example: { title: "Details", padding: 16, elevated: true },
},
ListItem: {
@@ -365,6 +386,11 @@ export const standardComponentDefinitions = {
slots: [],
description:
"List row with title, subtitle, and optional leading/trailing text. Bind on.press for the press handler.",
example: {
title: "Settings",
subtitle: "Manage your preferences",
showChevron: true,
},
},
Modal: {
+12 -10
View File
@@ -38,6 +38,8 @@ export const schema = defineSchema(
slots: s.array(s.string()),
/** Description for AI generation hints */
description: s.string(),
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
@@ -51,37 +53,37 @@ export const schema = defineSchema(
{
defaultRules: [
// Layout patterns
"FIXED BOTTOM BAR PATTERN: When building a screen with a fixed header and/or fixed bottom tab bar, the outermost Column must have flex:1 so it fills the SafeArea. The scrollable content area must also have flex:1. Structure: SafeArea > Column(flex:1, gap:0) > [header, Container(flex:1) > [ScrollContainer(...)], bottom-tabs]. Both the outer Column AND the content Container need flex:1.",
"NEVER place a bottom tab bar or fixed footer inside a ScrollContainer. It must be a sibling AFTER the flex:1 container that holds the ScrollContainer.",
"FIXED BOTTOM BAR PATTERN: When building a screen with a fixed header and/or fixed bottom tab bar, the outermost vertical layout component must have flex:1 so it fills the screen. The scrollable content area must also have flex:1. Structure: screen wrapper > vertical layout(flex:1, gap:0) > [header, content wrapper(flex:1) > [scroll container(...)], bottom-tabs]. Both the outer layout AND the content wrapper need flex:1. ONLY use components from the AVAILABLE COMPONENTS list.",
"NEVER place a bottom tab bar or fixed footer inside a scroll container. It must be a sibling AFTER the flex:1 container that holds the scroll content.",
// Element integrity
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
'When building repeating content backed by a state array (e.g. todos, posts, cart items), use the "repeat" field on the container element. Example: { "type": "Column", "props": { "gap": 8 }, "repeat": { "path": "/todos", "key": "id" }, "children": ["todo-item"] }. Inside repeated children, use "$item/field" for per-item state paths and "$index" for the current array index. Do NOT hardcode individual elements for each array item.',
'When building repeating content backed by a state array (e.g. todos, posts, cart items), use the "repeat" field on a container element from the AVAILABLE COMPONENTS list. Example: { "type": "<ContainerComponent>", "props": { "gap": 8 }, "repeat": { "path": "/todos", "key": "id" }, "children": ["todo-item"] }. Inside repeated children, use "$item/field" for per-item state paths and "$index" for the current array index. Do NOT hardcode individual elements for each array item.',
// Visible field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"Column","props":{"gap":8},"visible":{"eq":[{"path":"/activeTab"},"home"]},"children":[...]}. WRONG: {"type":"Column","props":{"gap":8,"visible":{...}},"children":[...]}.',
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"eq":[{"path":"/activeTab"},"home"]},"children":[...]}. WRONG: {"type":"<ComponentName>","props":{},"visible":{...},"children":[...]} with visible inside props.',
// Tab navigation pattern
"TAB NAVIGATION PATTERN: When building a UI with multiple tabs, use Pressable + setState action + visible conditions to make tabs functional.",
'Each tab button should be a Pressable wrapping its icon/label children, with action "setState" and actionParams { "path": "/activeTab", "value": "tabName" }.',
"TAB NAVIGATION PATTERN: When building a UI with multiple tabs, use a pressable/tappable component + setState action + visible conditions to make tabs functional. ONLY use components from the AVAILABLE COMPONENTS list.",
'Each tab button should be a pressable component wrapping its icon/label children, with action "setState" and actionParams { "path": "/activeTab", "value": "tabName" }.',
'Each tab\'s content section should have a visible condition: { "eq": [{ "path": "/activeTab" }, "tabName"] }.',
"The first tab's content should NOT have a visible condition (so it shows by default when no tab is selected yet). All other tabs MUST have a visible condition.",
// Tab active state highlighting (using dynamic props)
"TAB ACTIVE STYLING: Use $cond dynamic props on the Icon inside each tab Pressable so a single Icon changes appearance based on the active tab.",
"TAB ACTIVE STYLING: Use $cond dynamic props on icon elements inside each tab button so a single icon changes appearance based on the active tab.",
' - For the icon name: { "$cond": { "eq": [{ "path": "/activeTab" }, "thisTabName"] }, "$then": "home", "$else": "home-outline" }',
' - For the icon color: { "$cond": { "eq": [{ "path": "/activeTab" }, "thisTabName"] }, "$then": "#007AFF", "$else": "#8E8E93" }',
" - For labels, use $cond on the color prop similarly.",
' - For the FIRST/DEFAULT tab, use { "$cond": { "or": [{ "eq": [{ "path": "/activeTab" }, "thisTabName"] }, { "not": { "path": "/activeTab" } }] }, "$then": "#007AFF", "$else": "#8E8E93" } so it appears active before any tab is tapped.',
// Push/pop screen navigation (all screens in one spec)
'SCREEN NAVIGATION: Use Pressable with action "push" and actionParams { "screen": "screenName" } to navigate to a new screen. Use action "pop" to go back. All screens must be defined in the SAME spec.',
'SCREEN NAVIGATION: Use a pressable component with action "push" and actionParams { "screen": "screenName" } to navigate to a new screen. Use action "pop" to go back. All screens must be defined in the SAME spec. ONLY use components from the AVAILABLE COMPONENTS list.',
'Each screen section uses a visible condition on /currentScreen: { "eq": [{ "path": "/currentScreen" }, "screenName"] }. The default/home screen should also be visible when /currentScreen is not set: { "or": [{ "eq": [{ "path": "/currentScreen" }, "home"] }, { "not": { "path": "/currentScreen" } }] }.',
"push automatically maintains a /navStack in the state model so pop always returns to the previous screen.",
'Include a back button on pushed screens using action "pop". Example: Pressable(action:"pop") > Row > Icon(name:"chevron-back") + Label(text:"Back").',
'Include a back button on pushed screens using action "pop". Example: pressable(action:"pop") > row layout > back icon + back label. ONLY use components from the AVAILABLE COMPONENTS list.',
"Use push/pop for drill-down flows: tapping a list item to see details, opening a profile, etc. Use setState + visible conditions for tab switching within a screen.",
'Example: A list screen with items that push to detail: Pressable(action:"push", actionParams:{screen:"repo-detail"}) wrapping each list item card. The detail screen section has visible:{"eq":[{"path":"/currentScreen"},"repo-detail"]} and contains a back button with action:"pop".',
'Example: A list screen with items that push to detail: a pressable component with action:"push" and actionParams:{screen:"detail"} wrapping each list item. The detail screen section has visible:{"eq":[{"path":"/currentScreen"},"detail"]} and contains a back button with action:"pop". ONLY use components from the AVAILABLE COMPONENTS list.',
],
},
);
+8
View File
@@ -1,5 +1,13 @@
# @json-render/react
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.5.1",
"version": "0.5.2",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+6 -4
View File
@@ -38,6 +38,8 @@ export const schema = defineSchema(
slots: s.array(s.string()),
/** Description for AI generation hints */
description: s.string(),
/** Example prop values used in prompt examples (auto-generated from Zod schema if omitted) */
example: s.any(),
}),
/** Action definitions (optional) */
actions: s.map({
@@ -55,16 +57,16 @@ export const schema = defineSchema(
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
// Field placement
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"Stack","props":{"gap":"md"},"visible":{"eq":[{"path":"/tab"},"home"]},"children":[...]}.',
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"eq":[{"path":"/tab"},"home"]},"children":[...]}.',
'CRITICAL: The "on" field goes on the ELEMENT object, NOT inside "props". Use on.press, on.change, on.submit etc. NEVER put action/actionParams inside props.',
// State and data
"When the user asks for a UI that displays data (e.g. blog posts, products, users), ALWAYS include a state field with realistic sample data. The state field is a top-level field on the spec (sibling of root/elements).",
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on the container element. Example: { "type": "Grid", "props": { "columns": 3, "gap": "md" }, "repeat": { "path": "/posts", "key": "id" }, "children": ["post-card"] }. Inside repeated children, use "$item/field" for per-item state paths and "$index" for the current array index. Do NOT hardcode individual elements for each array item.',
'When building repeating content backed by a state array (e.g. posts, products, items), use the "repeat" field on a container element. Example: { "type": "<ContainerComponent>", "props": {}, "repeat": { "path": "/posts", "key": "id" }, "children": ["post-card"] }. Replace <ContainerComponent> with an appropriate component from the AVAILABLE COMPONENTS list. Inside repeated children, use "$item/field" for per-item state paths and "$index" for the current array index. Do NOT hardcode individual elements for each array item.',
// Design quality
"Design with visual hierarchy: use Cards to group content, Headings for section titles, proper spacing with Stack gaps, and Badges/colors for status indicators.",
"For data-rich UIs, use Grid for multi-column layouts (2-3 columns for cards/posts). For forms and single-column content, use Stack with direction:vertical.",
"Design with visual hierarchy: use container components to group content, heading components for section titles, proper spacing, and status indicators. ONLY use components from the AVAILABLE COMPONENTS list.",
"For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.",
"Always include realistic, professional-looking sample data. For blogs include 3-4 posts with varied titles, authors, dates, categories. For products include names, prices, images. Never leave data empty.",
],
},
+8
View File
@@ -1,5 +1,13 @@
# @json-render/remotion
## 0.5.2
### Patch Changes
- 429e456: Fix LLM hallucinations by dynamically generating prompt examples from the user's catalog instead of hardcoding component names. Adds optional `example` field to `ComponentDefinition` with Zod schema introspection fallback. Mentions RFC 6902 in output format section.
- Updated dependencies [429e456]
- @json-render/core@0.5.2
## 0.5.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.5.1",
"version": "0.5.2",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+11
View File
@@ -21,6 +21,7 @@ export const standardComponentDefinitions = {
defaultDuration: 90,
description:
"Full-screen title card with centered text. Use for intros, outros, and section breaks.",
example: { title: "Welcome", subtitle: "An introduction" },
},
ImageSlide: {
@@ -34,6 +35,11 @@ export const standardComponentDefinitions = {
defaultDuration: 150,
description:
"Full-screen image display. Use for product shots, photos, and visual content.",
example: {
src: "https://picsum.photos/1920/1080?random=1",
alt: "Hero image",
fit: "cover",
},
},
SplitScreen: {
@@ -61,6 +67,10 @@ export const standardComponentDefinitions = {
defaultDuration: 150,
description:
"Quote display with author. Props: quote, author, textColor, backgroundColor. Set transparent:true when using as overlay on images.",
example: {
quote: "The best way to predict the future is to invent it.",
author: "Alan Kay",
},
},
StatCard: {
@@ -74,6 +84,7 @@ export const standardComponentDefinitions = {
type: "scene",
defaultDuration: 90,
description: "Large statistic display. Use for key metrics and numbers.",
example: { value: "10M+", label: "Users worldwide", prefix: "" },
},
TypingText: {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN"],
"globalEnv": ["AI_GATEWAY_MODEL", "KV_REST_API_URL", "KV_REST_API_TOKEN", "RATE_LIMIT_PER_MINUTE", "RATE_LIMIT_PER_DAY"],
"tasks": {
"build": {
"dependsOn": ["^build"],