Compare commits

..
Author SHA1 Message Date
github-actions[bot] 22545b49cc chore: version packages (#221)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-13 17:05:54 -05:00
Chris Tate a8afd8bfe2 prepare v0.14 (#220) 2026-03-13 13:50:56 -05:00
Chris Tate dc489601be add yaml wire format and universal edit modes (#219)
* add yaml wire format and universal edit modes

**Summary**

- Add `@json-render/yaml` package with streaming parser, YAML prompt generation, and AI SDK transform
- Add universal edit modes (patch, merge, diff) to `@json-render/core`, usable across both JSONL and YAML formats
- Integrate YAML support and edit mode selection into the web playground with format toggle, token usage display, and prompt caching

* improvements

* fixes

* fixes ci

* docs
2026-03-13 13:37:21 -05:00
Chris Tate 7758d4bbd5 faster ci (#212)
* faster ci

* fix workflow
2026-03-12 22:47:02 -05:00
114 changed files with 4401 additions and 215 deletions
+2 -1
View File
@@ -21,7 +21,8 @@
"@json-render/mcp",
"@json-render/svelte",
"@json-render/solid",
"@json-render/react-three-fiber"
"@json-render/react-three-fiber",
"@json-render/yaml"
]
],
"linked": [],
+34 -25
View File
@@ -13,34 +13,43 @@ concurrency:
cancel-in-progress: true
jobs:
ci:
name: Lint, Type Check & Build
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- name: Install dependencies
run: pnpm install --frozen-lockfile
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build packages
run: pnpm turbo run build --filter='./packages/*'
- run: pnpm test
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm type-check
- name: Test
run: pnpm test
- name: Build
run: pnpm build
typecheck:
name: Type Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm type-check
+1
View File
@@ -135,6 +135,7 @@ function Dashboard({ spec }) {
| `@json-render/jotai` | Jotai adapter for `StateStore` |
| `@json-render/xstate` | XState Store (atom) adapter for `StateStore` |
| `@json-render/mcp` | MCP Apps integration for Claude, ChatGPT, Cursor, VS Code |
| `@json-render/yaml` | YAML wire format with streaming parser, edit modes, AI SDK transform |
## Renderers
+10
View File
@@ -1,5 +1,15 @@
# web
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/yaml@0.14.0
- @json-render/codegen@0.14.0
- @json-render/react@0.14.0
## 0.1.7
### Patch Changes
+93 -3
View File
@@ -76,6 +76,7 @@ interface PromptOptions {
system?: string; // Custom system message intro
customRules?: string[]; // Additional rules to append
mode?: "standalone" | "inline" | "generate" | "chat"; // Output mode (default: "standalone")
editModes?: EditMode[]; // Edit modes to document in prompt (default: ["patch"])
}
interface SpecValidationResult<T> {
@@ -480,9 +481,10 @@ function buildUserPrompt(options: UserPromptOptions): string
interface UserPromptOptions {
prompt: string; // The user's text prompt
currentSpec?: Spec | null; // Existing spec to refine (triggers patch-only mode)
currentSpec?: Spec | null; // Existing spec to refine (triggers edit mode)
state?: Record<string, unknown> | null; // Runtime state context to include
maxPromptLength?: number; // Max length for user text (truncates before wrapping)
editModes?: EditMode[]; // Edit modes for refinement (default: ["patch"])
}
```
@@ -492,14 +494,15 @@ interface UserPromptOptions {
const userPrompt = buildUserPrompt({ prompt: "create a todo app" });
```
### Refinement (patch-only mode)
### Refinement (edit modes)
When `currentSpec` is provided, the prompt instructs the AI to output only the patches needed for the change, not recreate the entire spec:
When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes instead of recreating the entire spec. Available modes: `"patch"` (RFC 6902), `"merge"` (RFC 7396), and `"diff"` (unified diff).
```typescript
const userPrompt = buildUserPrompt({
prompt: "add a dark mode toggle",
currentSpec: existingSpec,
editModes: ["patch", "merge"],
});
```
@@ -514,6 +517,93 @@ const userPrompt = buildUserPrompt({
});
```
## Edit Modes
Universal edit mode utilities for modifying existing specs. Used by `buildUserPrompt` internally and available for direct use.
```typescript
import {
buildEditInstructions,
buildEditUserPrompt,
isNonEmptySpec,
type EditMode,
type EditConfig,
} from '@json-render/core';
type EditMode = "patch" | "merge" | "diff";
```
### buildEditInstructions
Generate the prompt section describing available edit modes. Supports both JSON and YAML formats.
```typescript
function buildEditInstructions(config: EditConfig, format: "json" | "yaml"): string
const instructions = buildEditInstructions({ modes: ["patch", "merge"] }, "json");
```
### buildEditUserPrompt
Build a user prompt for editing an existing spec. Includes the current spec (with line numbers when diff mode is enabled) and mode-specific instructions.
```typescript
function buildEditUserPrompt(options: BuildEditUserPromptOptions): string
interface BuildEditUserPromptOptions {
prompt: string;
currentSpec?: Spec | null;
config?: EditConfig;
format: "json" | "yaml";
maxPromptLength?: number;
serializer?: (spec: Spec) => string;
}
```
### isNonEmptySpec
Check whether a value is a non-empty spec (has a root string and at least one element).
```typescript
function isNonEmptySpec(spec: unknown): spec is Spec
```
## Deep Merge and Diff
Format-agnostic utilities for merging and diffing spec objects.
### deepMergeSpec
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. Neither input is mutated.
```typescript
import { deepMergeSpec } from '@json-render/core';
function deepMergeSpec(
base: Record<string, unknown>,
patch: Record<string, unknown>
): Record<string, unknown>
const merged = deepMergeSpec(currentSpec, { elements: { main: { props: { title: "New" } } } });
```
### diffToPatches
Generate RFC 6902 JSON Patch operations that transform one object into another. Arrays are compared shallowly and replaced atomically; plain objects recurse.
```typescript
import { diffToPatches } from '@json-render/core';
function diffToPatches(
oldObj: Record<string, unknown>,
newObj: Record<string, unknown>,
basePath?: string
): JsonPatch[]
const patches = diffToPatches(oldSpec, newSpec);
// [{ op: "replace", path: "/elements/main/props/title", value: "New Title" }]
```
## evaluateVisibility
Evaluates a visibility condition against the state model.
+232
View File
@@ -0,0 +1,232 @@
import { pageMetadata } from "@/lib/page-metadata"
export const metadata = pageMetadata("docs/api/yaml")
# @json-render/yaml
YAML wire format for json-render. Progressive rendering and surgical edits via streaming YAML.
## Prompt Generation
### yamlPrompt
Generate a YAML-format system prompt from any json-render catalog. Works with catalogs from any renderer.
```typescript
function yamlPrompt(
catalog: Catalog,
options?: YamlPromptOptions
): string
```
```typescript
import { yamlPrompt } from "@json-render/yaml";
const systemPrompt = yamlPrompt(catalog, {
mode: "standalone",
customRules: ["Always use dark theme"],
editModes: ["merge"],
});
```
### YamlPromptOptions
<table>
<thead>
<tr>
<th>Option</th>
<th>Type</th>
<th>Default</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>system</code></td>
<td><code>string</code></td>
<td><code>{'\"You are a UI generator that outputs YAML.\"'}</code></td>
<td>Custom system message intro</td>
</tr>
<tr>
<td><code>mode</code></td>
<td><code>{'\"standalone\" | \"inline\"'}</code></td>
<td><code>{'\"standalone\"'}</code></td>
<td>Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences</td>
</tr>
<tr>
<td><code>customRules</code></td>
<td><code>{'string[]'}</code></td>
<td><code>{'[]'}</code></td>
<td>Additional rules appended to the prompt</td>
</tr>
<tr>
<td><code>editModes</code></td>
<td><code>{'EditMode[]'}</code></td>
<td><code>{'[\"merge\"]'}</code></td>
<td>Edit modes to document in the prompt (patch, merge, diff)</td>
</tr>
</tbody>
</table>
## AI SDK Transform
### createYamlTransform
Creates a `TransformStream` that intercepts AI SDK stream chunks and converts YAML spec/edit blocks into json-render patch data parts.
```typescript
function createYamlTransform(
options?: YamlTransformOptions
): TransformStream<StreamChunk, StreamChunk>
```
Recognized fence types:
- <code>{'```yaml-spec'}</code> -- Full YAML spec, parsed progressively
- <code>{'```yaml-edit'}</code> -- Partial YAML, deep-merged with current spec
- <code>{'```yaml-patch'}</code> -- RFC 6902 JSON Patch lines
- <code>{'```diff'}</code> -- Unified diff against serialized spec
### pipeYamlRender
Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
```typescript
function pipeYamlRender<T>(
stream: ReadableStream<T>,
options?: YamlTransformOptions
): ReadableStream<T>
```
```typescript
import { pipeYamlRender } from "@json-render/yaml";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeYamlRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
## Streaming Parser
### createYamlStreamCompiler
Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations by diffing each successful parse against the previous snapshot.
```typescript
function createYamlStreamCompiler<T>(
initial?: Partial<T>
): YamlStreamCompiler<T>
```
```typescript
import { createYamlStreamCompiler } from "@json-render/yaml";
const compiler = createYamlStreamCompiler<Spec>();
compiler.push("root: main\n");
compiler.push("elements:\n main:\n type: Card\n");
const { result, newPatches } = compiler.flush();
```
### YamlStreamCompiler
<table>
<thead>
<tr>
<th>Method</th>
<th>Returns</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>push(chunk)</code></td>
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
<td>Push a chunk of text, returns current result and new patches</td>
</tr>
<tr>
<td><code>flush()</code></td>
<td><code>{'{ result: T; newPatches: JsonPatch[] }'}</code></td>
<td>Flush remaining buffer, return final result</td>
</tr>
<tr>
<td><code>getResult()</code></td>
<td><code>T</code></td>
<td>Get the current compiled result</td>
</tr>
<tr>
<td><code>getPatches()</code></td>
<td><code>{'JsonPatch[]'}</code></td>
<td>Get all patches applied so far</td>
</tr>
<tr>
<td><code>reset(initial?)</code></td>
<td><code>void</code></td>
<td>Reset to initial state</td>
</tr>
</tbody>
</table>
## Fence Constants
Exported string constants for fence detection in custom parsers:
<table>
<thead>
<tr>
<th>Constant</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>YAML_SPEC_FENCE</code></td>
<td><code>{'\"```yaml-spec\"'}</code></td>
</tr>
<tr>
<td><code>YAML_EDIT_FENCE</code></td>
<td><code>{'\"```yaml-edit\"'}</code></td>
</tr>
<tr>
<td><code>YAML_PATCH_FENCE</code></td>
<td><code>{'\"```yaml-patch\"'}</code></td>
</tr>
<tr>
<td><code>DIFF_FENCE</code></td>
<td><code>{'\"```diff\"'}</code></td>
</tr>
<tr>
<td><code>FENCE_CLOSE</code></td>
<td><code>{'\"```\"'}</code></td>
</tr>
</tbody>
</table>
## Re-exports from @json-render/core
### diffToPatches
Generate RFC 6902 JSON Patch operations that transform one object into another.
```typescript
function diffToPatches(
oldObj: Record<string, unknown>,
newObj: Record<string, unknown>,
basePath?: string
): JsonPatch[]
```
### deepMergeSpec
Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse.
```typescript
function deepMergeSpec(
base: Record<string, unknown>,
patch: Record<string, unknown>
): Record<string, unknown>
```
+2 -2
View File
@@ -16,8 +16,8 @@ const SYSTEM_PROMPT = `You are a helpful documentation assistant for json-render
GitHub repository: https://github.com/vercel-labs/json-render
Documentation: https://json-render.dev/docs
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate. See /docs/skills for details.
npm packages: @json-render/core, @json-render/react, @json-render/vue, @json-render/svelte, @json-render/solid, @json-render/shadcn, @json-render/react-three-fiber, @json-render/react-native, @json-render/react-email, @json-render/react-pdf, @json-render/image, @json-render/remotion, @json-render/codegen, @json-render/mcp, @json-render/redux, @json-render/zustand, @json-render/jotai, @json-render/xstate, @json-render/yaml
Skills: json-render ships AI agent skills that teach coding agents how to use each package. Install with "npx skills add vercel-labs/json-render --skill <name>". Available skills: core, react, react-pdf, react-email, react-native, shadcn, react-three-fiber, image, remotion, vue, svelte, solid, codegen, mcp, redux, zustand, jotai, xstate, yaml. See /docs/skills for details.
You have access to the full json-render documentation via the bash and readFile tools. The docs are available as markdown files in the /workspace/docs/ directory.
+67 -13
View File
@@ -1,13 +1,19 @@
import { streamText } from "ai";
import { headers } from "next/headers";
import { buildUserPrompt } from "@json-render/core";
import type { Spec, EditMode } from "@json-render/core";
import {
buildUserPrompt,
buildEditUserPrompt,
isNonEmptySpec,
} from "@json-render/core";
import { yamlPrompt } from "@json-render/yaml";
import { stringify as yamlStringify } from "yaml";
import { minuteRateLimit, dailyRateLimit } from "@/lib/rate-limit";
import { playgroundCatalog } from "@/lib/render/catalog";
export const maxDuration = 30;
const SYSTEM_PROMPT = playgroundCatalog.prompt({
customRules: [
const PLAYGROUND_RULES = [
"NEVER use viewport height classes (min-h-screen, h-screen) - the UI renders inside a fixed-size container.",
"NEVER use page background colors (bg-gray-50) - the container has its own background.",
"For forms or small UIs: use Card as root with maxWidth:'sm' or 'md' and centered:true.",
@@ -15,18 +21,53 @@ const SYSTEM_PROMPT = playgroundCatalog.prompt({
"Wrap each repeated item in a Card for visual separation and structure.",
"Use realistic, professional sample data. Include 3-5 items with varied content. Never leave state arrays empty.",
'For form inputs (Input, Textarea, Select), always include checks for validation (e.g. required, email, minLength). Always pair checks with a $bindState expression on the value prop (e.g. { "$bindState": "/path" }).',
],
});
];
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string {
if (isYaml) {
return yamlPrompt(playgroundCatalog, {
mode: "standalone",
customRules: PLAYGROUND_RULES,
editModes: editModes ?? ["merge"],
});
}
return playgroundCatalog.prompt({
customRules: PLAYGROUND_RULES,
editModes,
});
}
function buildYamlUserPrompt(
prompt: string,
previousSpec?: Spec | null,
editModes?: EditMode[],
): string {
if (isNonEmptySpec(previousSpec)) {
return buildEditUserPrompt({
prompt,
currentSpec: previousSpec,
config: { modes: editModes ?? ["merge"] },
format: "yaml",
maxPromptLength: MAX_PROMPT_LENGTH,
serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
});
}
const userText = prompt.slice(0, MAX_PROMPT_LENGTH);
return [
userText,
"",
"Output the full spec in a ```yaml-spec fence. Stream progressively — output elements one at a time.",
].join("\n");
}
export async function POST(req: Request) {
// Get client IP for rate limiting
const headersList = await headers();
const ip = headersList.get("x-forwarded-for")?.split(",")[0] ?? "anonymous";
// Check rate limits (minute and daily)
const [minuteResult, dailyResult] = await Promise.all([
minuteRateLimit.limit(ip),
dailyRateLimit.limit(ip),
@@ -48,22 +89,34 @@ export async function POST(req: Request) {
);
}
const { prompt, context } = await req.json();
const { prompt, context, format, editModes } = await req.json();
const isYaml = format === "yaml";
const userPrompt = buildUserPrompt({
const systemPrompt = getSystemPrompt(isYaml, editModes);
const userPrompt = isYaml
? buildYamlUserPrompt(prompt, context?.previousSpec, editModes)
: buildUserPrompt({
prompt,
currentSpec: context?.previousSpec,
maxPromptLength: MAX_PROMPT_LENGTH,
editModes,
});
const result = streamText({
model: process.env.AI_GATEWAY_MODEL || DEFAULT_MODEL,
system: SYSTEM_PROMPT,
system: [
{
role: "system",
content: systemPrompt,
providerOptions: {
anthropic: { cacheControl: { type: "ephemeral" } },
},
},
],
prompt: userPrompt,
temperature: 0.7,
});
// Stream the text, then append token usage metadata at the end
const encoder = new TextEncoder();
const textStream = result.textStream;
@@ -72,7 +125,6 @@ export async function POST(req: Request) {
for await (const chunk of textStream) {
controller.enqueue(encoder.encode(chunk));
}
// Append usage metadata after stream completes
try {
const usage = await result.usage;
const meta = JSON.stringify({
@@ -80,10 +132,12 @@ export async function POST(req: Request) {
promptTokens: usage.inputTokens,
completionTokens: usage.outputTokens,
totalTokens: usage.totalTokens,
cachedTokens: usage.inputTokenDetails?.cacheReadTokens ?? 0,
cacheWriteTokens: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
});
controller.enqueue(encoder.encode(`\n${meta}\n`));
} catch {
// Usage not available — skip silently
// Usage not available
}
controller.close();
},
+2 -2
View File
@@ -145,7 +145,7 @@ function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [vercelLightTheme, vercelDarkTheme],
langs: ["json", "tsx", "typescript"],
langs: ["json", "tsx", "typescript", "yaml"],
});
}
return highlighterPromise;
@@ -158,7 +158,7 @@ if (typeof window !== "undefined") {
interface CodeBlockProps {
code: string;
lang: "json" | "tsx" | "typescript";
lang: "json" | "tsx" | "typescript" | "yaml";
fillHeight?: boolean;
hideCopyButton?: boolean;
}
+166 -48
View File
@@ -2,10 +2,16 @@
import { useEffect, useState, useCallback, useRef, useMemo } from "react";
import { flushSync } from "react-dom";
import { useUIStream, type TokenUsage } from "@json-render/react";
import type { Spec } from "@json-render/core";
import { collectUsedComponents, serializeProps } from "@json-render/codegen";
import { toast } from "sonner";
import { stringify as yamlStringify } from "yaml";
import type { EditMode } from "@json-render/core";
import {
usePlaygroundStream,
type StreamFormat,
type TokenUsage,
} from "@/lib/use-playground-stream";
import {
ResizablePanelGroup,
ResizablePanel,
@@ -22,10 +28,10 @@ import { PlaygroundRenderer } from "@/lib/render/renderer";
import { playgroundCatalog } from "@/lib/render/catalog";
import { buildCatalogDisplayData } from "@/lib/render/catalog-display";
type Tab = "json" | "nested" | "stream" | "catalog" | "visual";
type Tab = "spec" | "nested" | "stream" | "catalog" | "visual";
type RenderView = "preview" | "code";
type MobileView =
| "json"
| "spec"
| "nested"
| "stream"
| "catalog"
@@ -40,6 +46,79 @@ interface Version {
status: "generating" | "complete" | "error";
usage: TokenUsage | null;
rawLines: string[];
format: StreamFormat;
}
function formatTokens(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
return String(n);
}
function PlaygroundControls({
format,
setFormat,
editModes,
setEditModes,
showClear,
onClear,
}: {
format: StreamFormat;
setFormat: (f: StreamFormat) => void;
editModes: EditMode[];
setEditModes: React.Dispatch<React.SetStateAction<EditMode[]>>;
showClear: boolean;
onClear: () => void;
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["jsonl", "yaml"] as const).map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`px-1.5 py-0.5 transition-colors ${
format === f
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{f}
</button>
))}
</div>
<div className="flex items-center rounded border border-border text-[10px] font-mono overflow-hidden">
{(["patch", "merge", "diff"] as const).map((m) => (
<button
key={m}
onClick={() => {
setEditModes((prev) =>
prev.includes(m)
? prev.length > 1
? prev.filter((x) => x !== m)
: prev
: [...prev, m],
);
}}
className={`px-1.5 py-0.5 transition-colors ${
editModes.includes(m)
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
{m}
</button>
))}
</div>
{showClear && (
<button
onClick={onClear}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
)}
</div>
);
}
/**
@@ -100,13 +179,15 @@ export function Playground() {
null,
);
const [inputValue, setInputValue] = useState("");
const [activeTab, setActiveTab] = useState<Tab>("json");
const [activeTab, setActiveTab] = useState<Tab>("spec");
const [catalogSection, setCatalogSection] = useState<
"components" | "actions"
>("components");
const [renderView, setRenderView] = useState<RenderView>("preview");
const [mobileView, setMobileView] = useState<MobileView>("preview");
const [versionsSheetOpen, setVersionsSheetOpen] = useState(false);
const [format, setFormat] = useState<StreamFormat>("jsonl");
const [editModes, setEditModes] = useState<EditMode[]>(["patch"]);
const inputRef = useRef<HTMLTextAreaElement>(null);
const mobileInputRef = useRef<HTMLTextAreaElement>(null);
const versionsEndRef = useRef<HTMLDivElement>(null);
@@ -124,12 +205,13 @@ export function Playground() {
rawLines: streamRawLines,
send,
clear,
} = useUIStream({
} = usePlaygroundStream({
api: "/api/generate",
format,
editModes,
onError: (err: Error) => {
console.error("Generation error:", err);
toast.error(err.message || "Generation failed. Please try again.");
// Mark the version as errored
if (generatingVersionIdRef.current) {
const erroredVersionId = generatingVersionIdRef.current;
setVersions((prev) =>
@@ -140,7 +222,7 @@ export function Playground() {
generatingVersionIdRef.current = null;
}
},
} as Parameters<typeof useUIStream>[0]);
});
// Get the selected version
const selectedVersion = versions.find((v) => v.id === selectedVersionId);
@@ -215,6 +297,7 @@ export function Playground() {
status: "generating",
usage: null,
rawLines: [],
format,
};
generatingVersionIdRef.current = newVersionId;
@@ -224,7 +307,7 @@ export function Playground() {
// Pass the current tree as context so the API can iterate on it
await send(inputValue.trim(), { previousSpec: currentTreeRef.current });
}, [inputValue, isStreaming, send]);
}, [inputValue, isStreaming, send, format]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -250,9 +333,16 @@ export function Playground() {
[selectedVersionId, isStreaming],
);
const jsonCode = currentTree
? JSON.stringify(currentTree, null, 2)
: "// waiting...";
const specCode = useMemo(() => {
if (!currentTree)
return format === "yaml" ? "# waiting..." : "// waiting...";
if (format === "yaml") {
return yamlStringify(currentTree, { indent: 2 }).trimEnd();
}
return JSON.stringify(currentTree, null, 2);
}, [currentTree, format]);
const specLang = format === "yaml" ? "yaml" : "json";
const nestedCode = useMemo(() => {
if (!currentTree || !currentTree.root) return "// waiting...";
@@ -275,7 +365,7 @@ export function Playground() {
const componentName = element.type;
const propsObj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(element.props)) {
for (const [k, v] of Object.entries(element.props ?? {})) {
if (v !== null && v !== undefined) {
propsObj[k] = v;
}
@@ -321,6 +411,15 @@ ${jsx}
}`;
}, [currentTree]);
// Determine syntax lang for raw stream based on selected version's format
const streamLang = isSelectedVersionGenerating
? format === "yaml"
? "yaml"
: "json"
: selectedVersion?.format === "yaml"
? "yaml"
: "json";
// Chat pane content
const chatPane = (
<div className="h-full flex flex-col border-t border-border">
@@ -392,9 +491,15 @@ ${jsx}
)}
</div>
{version.usage && (
<div className="flex items-center gap-2 mt-1 ml-6">
<div className="mt-1 ml-6">
<span className="text-[10px] font-mono text-muted-foreground/60">
{version.usage.totalTokens.toLocaleString()} tokens
{formatTokens(
version.usage.promptTokens - version.usage.cachedTokens,
)}{" "}
in · {formatTokens(version.usage.completionTokens)} out
{version.usage.cachedTokens > 0
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
: ""}
</span>
</div>
)}
@@ -425,20 +530,18 @@ ${jsx}
autoFocus
/>
<div className="flex justify-between items-center mt-2">
{versions.length > 0 ? (
<button
onClick={() => {
<PlaygroundControls
format={format}
setFormat={setFormat}
editModes={editModes}
setEditModes={setEditModes}
showClear={versions.length > 0}
onClear={() => {
setVersions([]);
setSelectedVersionId(null);
clear();
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
) : (
<div />
)}
/>
{isStreaming ? (
<button
onClick={() => clear()}
@@ -492,18 +595,18 @@ ${jsx}
const copyText =
activeTab === "stream"
? currentRawLines.join("\n")
: activeTab === "json"
? jsonCode
: activeTab === "spec"
? specCode
: activeTab === "nested"
? nestedCode
: activeTab === "visual"
? jsonCode
? specCode
: "";
const codePane = (
<div className="h-full flex flex-col border-t border-border">
<div className="border-b border-border px-3 h-9 flex items-center gap-3">
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
@@ -514,7 +617,7 @@ ${jsx}
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
</button>
),
)}
@@ -679,7 +782,7 @@ ${jsx}
currentRawLines.length > 0 ? (
<CodeBlock
code={currentRawLines.join("\n")}
lang="json"
lang={streamLang}
fillHeight
hideCopyButton
/>
@@ -691,7 +794,12 @@ ${jsx}
) : activeTab === "nested" ? (
<CodeBlock code={nestedCode} lang="json" fillHeight hideCopyButton />
) : (
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
<CodeBlock
code={specCode}
lang={specLang}
fillHeight
hideCopyButton
/>
)}
</div>
</div>
@@ -790,7 +898,7 @@ ${jsx}
: 0}
</button>
{/* Code tabs */}
{(["json", "visual", "nested", "stream", "catalog"] as const).map(
{(["spec", "visual", "nested", "stream", "catalog"] as const).map(
(tab) => (
<button
key={tab}
@@ -801,7 +909,7 @@ ${jsx}
: "text-muted-foreground hover:text-foreground"
}`}
>
{tab}
{tab === "spec" ? (format === "yaml" ? "yaml" : "json") : tab}
</button>
),
)}
@@ -982,7 +1090,7 @@ ${jsx}
currentRawLines.length > 0 ? (
<CodeBlock
code={currentRawLines.join("\n")}
lang="json"
lang={streamLang}
fillHeight
hideCopyButton
/>
@@ -998,8 +1106,13 @@ ${jsx}
fillHeight
hideCopyButton
/>
) : mobileView === "json" ? (
<CodeBlock code={jsonCode} lang="json" fillHeight hideCopyButton />
) : mobileView === "spec" ? (
<CodeBlock
code={specCode}
lang={specLang}
fillHeight
hideCopyButton
/>
) : mobileView === "preview" ? (
currentTree && currentTree.root ? (
<div className="w-full min-h-full flex items-center justify-center p-6">
@@ -1075,20 +1188,18 @@ ${jsx}
rows={2}
/>
<div className="flex justify-between items-center mt-2">
{versions.length > 0 ? (
<button
onClick={() => {
<PlaygroundControls
format={format}
setFormat={setFormat}
editModes={editModes}
setEditModes={setEditModes}
showClear={versions.length > 0}
onClear={() => {
setVersions([]);
setSelectedVersionId(null);
clear();
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
) : (
<div />
)}
/>
{isStreaming ? (
<button
onClick={() => clear()}
@@ -1165,9 +1276,16 @@ ${jsx}
)}
</div>
{version.usage && (
<div className="flex items-center gap-2 mt-1 ml-6">
<div className="mt-1 ml-6">
<span className="text-[10px] font-mono text-muted-foreground/60">
{version.usage.totalTokens.toLocaleString()} tokens
{formatTokens(
version.usage.promptTokens -
version.usage.cachedTokens,
)}{" "}
in · {formatTokens(version.usage.completionTokens)} out
{version.usage.cachedTokens > 0
? ` · ${formatTokens(version.usage.cachedTokens)} cached`
: ""}
</span>
</div>
)}
+1
View File
@@ -144,6 +144,7 @@ export const docsNavigation: NavSection[] = [
{ title: "@json-render/zustand", href: "/docs/api/zustand" },
{ title: "@json-render/jotai", href: "/docs/api/jotai" },
{ title: "@json-render/xstate", href: "/docs/api/xstate" },
{ title: "@json-render/yaml", href: "/docs/api/yaml" },
],
},
];
+1
View File
@@ -58,6 +58,7 @@ export const PAGE_TITLES: Record<string, string> = {
"docs/api/jotai": "@json-render/jotai API",
"docs/api/react-three-fiber": "@json-render/react-three-fiber API",
"docs/api/xstate": "@json-render/xstate API",
"docs/api/yaml": "@json-render/yaml API",
};
/**
+155
View File
@@ -0,0 +1,155 @@
import type { Spec, JsonPatch } from "@json-render/core";
import { setByPath, getByPath, removeByPath } from "@json-render/core";
export function setSpecValue(
newSpec: Spec,
path: string,
value: unknown,
): void {
if (path === "/root") {
newSpec.root = value as string;
return;
}
if (path === "/state") {
newSpec.state = value as Record<string, unknown>;
return;
}
if (path.startsWith("/state/")) {
if (!newSpec.state) newSpec.state = {};
setByPath(
newSpec.state as Record<string, unknown>,
path.slice("/state".length),
value,
);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
if (value == null || typeof value !== "object") return;
const el = value as Record<string, unknown>;
newSpec.elements[elementKey] = {
...el,
type: typeof el.type === "string" ? el.type : "",
props: el.props != null && typeof el.props === "object" ? el.props : {},
children: Array.isArray(el.children) ? el.children : [],
} as Spec["elements"][string];
} else {
const element = newSpec.elements[elementKey];
if (element) {
const newElement = { ...element };
setByPath(
newElement as unknown as Record<string, unknown>,
"/" + pathParts.slice(1).join("/"),
value,
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
export function removeSpecValue(newSpec: Spec, path: string): void {
if (path === "/state") {
delete newSpec.state;
return;
}
if (path.startsWith("/state/") && newSpec.state) {
removeByPath(
newSpec.state as Record<string, unknown>,
path.slice("/state".length),
);
return;
}
if (path.startsWith("/elements/")) {
const pathParts = path.slice("/elements/".length).split("/");
const elementKey = pathParts[0];
if (!elementKey) return;
if (pathParts.length === 1) {
delete newSpec.elements[elementKey];
} else {
const element = newSpec.elements[elementKey];
if (element) {
const newElement = { ...element };
removeByPath(
newElement as unknown as Record<string, unknown>,
"/" + pathParts.slice(1).join("/"),
);
newSpec.elements[elementKey] = newElement;
}
}
}
}
export function getSpecValue(spec: Spec, path: string): unknown {
if (path === "/root") return spec.root;
if (path === "/state") return spec.state;
if (path.startsWith("/state/") && spec.state) {
return getByPath(
spec.state as Record<string, unknown>,
path.slice("/state".length),
);
}
return getByPath(spec as unknown as Record<string, unknown>, path);
}
export function normalizeSpec(spec: Spec): void {
if (
spec.state === null ||
(spec.state !== undefined && typeof spec.state !== "object")
) {
spec.state = undefined;
}
for (const key of Object.keys(spec.elements)) {
const el = spec.elements[key];
if (!el || typeof el !== "object") {
delete spec.elements[key];
continue;
}
if (el.props == null || typeof el.props !== "object") {
spec.elements[key] = { ...el, props: {} } as Spec["elements"][string];
}
if (!Array.isArray(spec.elements[key]!.children)) {
spec.elements[key] = {
...spec.elements[key]!,
children: [],
} as Spec["elements"][string];
}
}
}
export function applySpecPatch(spec: Spec, patch: JsonPatch): Spec {
const newSpec = {
...spec,
elements: { ...spec.elements },
...(spec.state ? { state: { ...spec.state } } : {}),
};
switch (patch.op) {
case "add":
case "replace":
setSpecValue(newSpec, patch.path, patch.value);
break;
case "remove":
removeSpecValue(newSpec, patch.path);
break;
case "move":
if (patch.from) {
const moveValue = getSpecValue(newSpec, patch.from);
removeSpecValue(newSpec, patch.from);
setSpecValue(newSpec, patch.path, moveValue);
}
break;
case "copy":
if (patch.from) {
setSpecValue(newSpec, patch.path, getSpecValue(newSpec, patch.from));
}
break;
case "test":
break;
}
normalizeSpec(newSpec);
return newSpec;
}
+482
View File
@@ -0,0 +1,482 @@
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import type { Spec, JsonPatch, EditMode } from "@json-render/core";
import { deepMergeSpec, diffToPatches } from "@json-render/core";
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
import { applyPatch as applyUnifiedDiff } from "diff";
import {
createYamlStreamCompiler,
YAML_SPEC_FENCE,
YAML_EDIT_FENCE,
YAML_PATCH_FENCE,
DIFF_FENCE,
FENCE_CLOSE,
} from "@json-render/yaml";
import { applySpecPatch } from "./spec-patch";
export type StreamFormat = "jsonl" | "yaml";
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
}
export interface UsePlaygroundStreamOptions {
api: string;
format: StreamFormat;
editModes?: EditMode[];
onError?: (error: Error) => void;
onComplete?: (spec: Spec) => void;
}
export interface UsePlaygroundStreamReturn {
spec: Spec | null;
isStreaming: boolean;
error: Error | null;
usage: TokenUsage | null;
rawLines: string[];
send: (prompt: string, context?: Record<string, unknown>) => Promise<void>;
clear: () => void;
}
// ── JSONL helpers ──
type ParsedLine =
| { type: "patch"; patch: JsonPatch }
| { type: "usage"; usage: TokenUsage }
| { type: "json-edit"; mergeObj: Record<string, unknown> }
| null;
function parseLine(line: string): ParsedLine {
try {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("//")) return null;
const parsed = JSON.parse(trimmed);
if (parsed.__meta === "usage") {
return {
type: "usage",
usage: {
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
cachedTokens: parsed.cachedTokens ?? 0,
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
},
};
}
if (parsed.__json_edit === true) {
const mergeObj = { ...parsed };
delete mergeObj.__json_edit;
return { type: "json-edit", mergeObj };
}
return { type: "patch", patch: parsed as JsonPatch };
} catch {
return null;
}
}
type FenceState = "outside" | "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff";
// ── Hook ──
export function usePlaygroundStream({
api,
format,
editModes,
onError,
onComplete,
}: UsePlaygroundStreamOptions): UsePlaygroundStreamReturn {
const [spec, setSpec] = useState<Spec | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [usage, setUsage] = useState<TokenUsage | null>(null);
const [rawLines, setRawLines] = useState<string[]>([]);
const rawLinesRef = useRef<string[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
const onCompleteRef = useRef(onComplete);
onCompleteRef.current = onComplete;
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const formatRef = useRef(format);
formatRef.current = format;
const editModesRef = useRef(editModes);
editModesRef.current = editModes;
const clear = useCallback(() => {
setSpec(null);
setError(null);
}, []);
const send = useCallback(
async (prompt: string, context?: Record<string, unknown>) => {
abortControllerRef.current = new AbortController();
setIsStreaming(true);
setError(null);
setUsage(null);
rawLinesRef.current = [];
setRawLines([]);
const previousSpec = context?.previousSpec as Spec | undefined;
let currentSpec: Spec =
previousSpec && previousSpec.root
? { ...previousSpec, elements: { ...previousSpec.elements } }
: { root: "", elements: {} };
setSpec(currentSpec);
try {
const response = await fetch(api, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt,
context,
format: formatRef.current,
editModes: editModesRef.current,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
let errorMessage = `HTTP error: ${response.status}`;
try {
const errorData = await response.json();
if (errorData.message) errorMessage = errorData.message;
else if (errorData.error) errorMessage = errorData.error;
} catch {
// use default
}
throw new Error(errorMessage);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
if (formatRef.current === "yaml") {
// ── YAML streaming ──
let fenceState: FenceState = "outside";
const compiler = createYamlStreamCompiler<Record<string, unknown>>();
let yamlEditAccumulated = "";
let yamlPatchAccumulated = "";
let diffAccumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
// Check for usage metadata (appended after stream)
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed.__meta === "usage") {
setUsage({
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
cachedTokens: parsed.cachedTokens ?? 0,
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
});
continue;
}
} catch {
// not JSON
}
}
rawLinesRef.current.push(line);
if (fenceState === "outside") {
if (
trimmed === YAML_SPEC_FENCE ||
trimmed.startsWith(YAML_SPEC_FENCE + " ")
) {
fenceState = "yaml-spec";
compiler.reset();
} else if (
trimmed === YAML_EDIT_FENCE ||
trimmed.startsWith(YAML_EDIT_FENCE + " ")
) {
fenceState = "yaml-edit";
yamlEditAccumulated = "";
} else if (
trimmed === YAML_PATCH_FENCE ||
trimmed.startsWith(YAML_PATCH_FENCE + " ")
) {
fenceState = "yaml-patch";
yamlPatchAccumulated = "";
} else if (
trimmed === DIFF_FENCE ||
trimmed.startsWith(DIFF_FENCE + " ")
) {
fenceState = "diff";
diffAccumulated = "";
}
} else if (trimmed === FENCE_CLOSE || trimmed === "````") {
if (fenceState === "yaml-spec") {
const { result, newPatches } = compiler.flush();
if (result && typeof result === "object" && result.root) {
for (const patch of newPatches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
} else if (fenceState === "yaml-edit") {
try {
const editObj = yamlParse(yamlEditAccumulated);
if (editObj && typeof editObj === "object") {
const merged = deepMergeSpec(
currentSpec as unknown as Record<string, unknown>,
editObj as Record<string, unknown>,
);
const patches = diffToPatches(
currentSpec as unknown as Record<string, unknown>,
merged,
);
for (const patch of patches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
} catch {
// Invalid YAML edit
}
} else if (fenceState === "yaml-patch") {
for (const patchLine of yamlPatchAccumulated.split("\n")) {
const t = patchLine.trim();
if (!t) continue;
try {
const patch = JSON.parse(t) as JsonPatch;
if (patch.op) {
currentSpec = applySpecPatch(currentSpec, patch);
}
} catch {
// Skip invalid JSON lines
}
}
setSpec({ ...currentSpec });
} else if (fenceState === "diff") {
try {
const specYaml = yamlStringify(currentSpec, { indent: 2 });
const patched = applyUnifiedDiff(specYaml, diffAccumulated);
if (typeof patched === "string") {
const parsed = yamlParse(patched);
if (parsed && typeof parsed === "object") {
const patches = diffToPatches(
currentSpec as unknown as Record<string, unknown>,
parsed as Record<string, unknown>,
);
for (const patch of patches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
}
} catch {
// Diff apply or reparse failed
}
}
fenceState = "outside";
} else if (fenceState === "yaml-spec") {
const { newPatches } = compiler.push(line + "\n");
if (newPatches.length > 0) {
for (const patch of newPatches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
} else if (fenceState === "yaml-edit") {
yamlEditAccumulated += line + "\n";
} else if (fenceState === "yaml-patch") {
yamlPatchAccumulated += line + "\n";
} else if (fenceState === "diff") {
diffAccumulated += line + "\n";
}
}
setRawLines([...rawLinesRef.current]);
}
// Process remaining buffer
if (buffer.trim()) {
const trimmed = buffer.trim();
if (trimmed.startsWith("{") && trimmed.includes('"__meta"')) {
try {
const parsed = JSON.parse(trimmed);
if (parsed.__meta === "usage") {
setUsage({
promptTokens: parsed.promptTokens ?? 0,
completionTokens: parsed.completionTokens ?? 0,
totalTokens: parsed.totalTokens ?? 0,
cachedTokens: parsed.cachedTokens ?? 0,
cacheWriteTokens: parsed.cacheWriteTokens ?? 0,
});
}
} catch {
// not JSON
}
} else if (fenceState === "yaml-spec") {
compiler.push(buffer);
const { result, newPatches } = compiler.flush();
if (result && typeof result === "object" && result.root) {
for (const patch of newPatches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
}
}
} else {
// ── JSONL streaming ──
let jsonlDiffState: "outside" | "diff" = "outside";
let jsonlDiffAccumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Diff fence detection within JSONL mode
if (jsonlDiffState === "outside") {
if (
trimmed === DIFF_FENCE ||
trimmed.startsWith(DIFF_FENCE + " ")
) {
jsonlDiffState = "diff";
jsonlDiffAccumulated = "";
continue;
}
} else if (
jsonlDiffState === "diff" &&
(trimmed === FENCE_CLOSE || trimmed === "````")
) {
try {
const specJson = JSON.stringify(currentSpec, null, 2);
const patched = applyUnifiedDiff(
specJson,
jsonlDiffAccumulated,
);
if (typeof patched === "string") {
const parsed = JSON.parse(patched);
if (parsed && typeof parsed === "object") {
const patches = diffToPatches(
currentSpec as unknown as Record<string, unknown>,
parsed as Record<string, unknown>,
);
for (const patch of patches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
setSpec({ ...currentSpec });
}
}
} catch {
// Diff apply failed
}
jsonlDiffState = "outside";
continue;
}
if (jsonlDiffState === "diff") {
jsonlDiffAccumulated += line + "\n";
rawLinesRef.current.push(line);
continue;
}
// Standard JSONL line parsing
const result = parseLine(trimmed);
if (!result) continue;
if (result.type === "usage") {
setUsage(result.usage);
} else if (result.type === "json-edit") {
const merged = deepMergeSpec(
currentSpec as unknown as Record<string, unknown>,
result.mergeObj,
);
const patches = diffToPatches(
currentSpec as unknown as Record<string, unknown>,
merged,
);
for (const patch of patches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
rawLinesRef.current.push(trimmed);
setSpec({ ...currentSpec });
} else {
rawLinesRef.current.push(trimmed);
currentSpec = applySpecPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
setRawLines([...rawLinesRef.current]);
}
if (buffer.trim()) {
const trimmed = buffer.trim();
const result = parseLine(trimmed);
if (result) {
if (result.type === "usage") {
setUsage(result.usage);
} else if (result.type === "json-edit") {
const merged = deepMergeSpec(
currentSpec as unknown as Record<string, unknown>,
result.mergeObj,
);
const patches = diffToPatches(
currentSpec as unknown as Record<string, unknown>,
merged,
);
for (const patch of patches) {
currentSpec = applySpecPatch(currentSpec, patch);
}
rawLinesRef.current.push(trimmed);
setSpec({ ...currentSpec });
} else {
rawLinesRef.current.push(trimmed);
currentSpec = applySpecPatch(currentSpec, result.patch);
setSpec({ ...currentSpec });
}
}
}
}
onCompleteRef.current?.(currentSpec);
} catch (err) {
if ((err as Error).name === "AbortError") return;
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onErrorRef.current?.(error);
} finally {
setIsStreaming(false);
}
},
[api],
);
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
return { spec, isStreaming, error, usage, rawLines, send, clear };
}
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"license": "Apache-2.0",
@@ -18,6 +18,7 @@
"@json-render/codegen": "workspace:*",
"@json-render/core": "workspace:*",
"@json-render/react": "workspace:*",
"@json-render/yaml": "workspace:*",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
@@ -34,9 +35,9 @@
"bash-tool": "1.3.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^8.0.3",
"embla-carousel-react": "^8.6.0",
"geist": "1.7.0",
"just-bash": "2.9.6",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
@@ -51,6 +52,7 @@
"tailwind-merge": "^3.4.0",
"unist-util-visit": "5.1.0",
"vaul": "^1.1.2",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
+9
View File
@@ -1,5 +1,14 @@
# example-chat
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
- @json-render/shadcn@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-chat",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-dashboard
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/codegen@0.14.0
- @json-render/react@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-dashboard",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-image
## 0.1.5
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/image@0.14.0
## 0.1.4
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-image",
"version": "0.1.4",
"version": "0.1.5",
"type": "module",
"private": true,
"scripts": {
+10
View File
@@ -1,5 +1,15 @@
# example-mcp
## 0.1.4
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/mcp@0.14.0
- @json-render/react@0.14.0
- @json-render/shadcn@0.14.0
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-mcp",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-no-ai
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
- @json-render/shadcn@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-no-ai",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-email
## 0.1.4
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react-email@0.14.0
## 0.1.3
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-email",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-native
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react-native@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-native",
"version": "0.1.7",
"version": "0.1.8",
"private": true,
"main": "expo-router/entry",
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-react-pdf
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react-pdf@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-pdf",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+9
View File
@@ -1,5 +1,14 @@
# example-react-three-fiber
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
- @json-render/react-three-fiber@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-react-three-fiber",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-remotion
## 0.1.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/remotion@0.14.0
## 0.1.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-remotion",
"version": "0.1.7",
"version": "0.1.8",
"type": "module",
"private": true,
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-solid
## 0.1.2
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/solid@0.14.0
## 0.1.1
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-solid",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"scripts": {
@@ -1,5 +1,13 @@
# com.example.json-render-demo
## 0.0.9
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
## 0.0.8
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-demo",
"version": "0.0.8",
"version": "0.0.9",
"description": "Test",
"private": true,
"license": "~~proprietary~~",
@@ -1,5 +1,13 @@
# com.example.json-render-fullpage-demo
## 0.0.9
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
## 0.0.8
### Patch Changes
@@ -1,6 +1,6 @@
{
"name": "com.example.json-render-fullpage-demo",
"version": "0.0.8",
"version": "0.0.9",
"description": "Full-page Stripe App example (alpha)",
"private": true,
"license": "~~proprietary~~",
+8
View File
@@ -1,5 +1,13 @@
# svelte-chat
## 0.0.5
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/svelte@0.14.0
## 0.0.4
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "svelte-chat",
"private": true,
"version": "0.0.4",
"version": "0.0.5",
"type": "module",
"scripts": {
"dev": "vite dev",
+8
View File
@@ -1,5 +1,13 @@
# example-svelte
## 0.1.5
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/svelte@0.14.0
## 0.1.4
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-svelte",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"type": "module",
"scripts": {
+11
View File
@@ -1,5 +1,16 @@
# vite-renderers
## 0.1.6
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
- @json-render/solid@0.14.0
- @json-render/svelte@0.14.0
- @json-render/vue@0.14.0
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "vite-renderers",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"type": "module",
"scripts": {
+8
View File
@@ -1,5 +1,13 @@
# example-vue
## 0.1.6
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/vue@0.14.0
## 0.1.5
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "example-vue",
"version": "0.1.5",
"version": "0.1.6",
"private": true,
"scripts": {
"predev": "command -v portless >/dev/null 2>&1 || (echo '\\nportless is required but not installed. Run: npm i -g portless\\nSee: https://github.com/vercel-labs/portless\\n' && exit 1)",
+7
View File
@@ -1,5 +1,12 @@
# @json-render/codegen
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/codegen",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees",
"keywords": [
+15
View File
@@ -1,5 +1,20 @@
# @json-render/core
## 0.14.0
### Minor Changes
- a8afd8b: Add YAML wire format package and universal edit modes for surgical spec refinement.
### New:
- **`@json-render/yaml`** -- YAML wire format for json-render. Includes streaming YAML parser, `yamlPrompt()` for system prompts, and AI SDK transform (`pipeYamlRender`) as a drop-in alternative to JSONL streaming. Supports four fence types: `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff`.
- **Universal edit modes** in `@json-render/core` -- three strategies for multi-turn spec refinement: `patch` (RFC 6902), `merge` (RFC 7396), and `diff` (unified diff). New `editModes` option on `buildUserPrompt()` and `PromptOptions`. New helpers: `deepMergeSpec()`, `diffToPatches()`, `buildEditUserPrompt()`, `buildEditInstructions()`, `isNonEmptySpec()`.
### Improved:
- **Playground** -- format toggle (JSONL / YAML), edit mode picker (patch / merge / diff), and token usage display with prompt caching stats.
- **Prompt caching** -- generate API uses Anthropic ephemeral cache control for system prompts.
- **CI** -- lint, type-check, and test jobs now run in parallel.
## 0.13.0
### Minor Changes
+36 -1
View File
@@ -210,7 +210,20 @@ Schema options:
| Export | Purpose |
|--------|---------|
| `buildUserPrompt(options)` | Build a user prompt with optional spec refinement and state context |
| `buildEditUserPrompt(options)` | Build a user prompt for editing an existing spec (used internally by `buildUserPrompt`) |
| `buildEditInstructions(config, format)` | Generate the prompt section describing available edit modes |
| `isNonEmptySpec(spec)` | Check whether a spec has a root and at least one element |
| `UserPromptOptions` | Options type for `buildUserPrompt` |
| `EditMode` | `"patch" \| "merge" \| "diff"` |
| `EditConfig` | Configuration for edit modes (`{ modes: EditMode[] }`) |
| `BuildEditUserPromptOptions` | Options type for `buildEditUserPrompt` |
### Merge and Diff
| Export | Purpose |
|--------|---------|
| `deepMergeSpec(base, patch)` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) |
| `diffToPatches(oldObj, newObj)` | Generate RFC 6902 JSON Patch operations from object diff |
### Spec Validation
@@ -491,10 +504,11 @@ import { buildUserPrompt } from "@json-render/core";
// Fresh generation
const prompt = buildUserPrompt({ prompt: "create a todo app" });
// Refinement with existing spec (triggers patch-only mode)
// Refinement with edit modes (default: patch-only)
const refinementPrompt = buildUserPrompt({
prompt: "add a dark mode toggle",
currentSpec: existingSpec,
editModes: ["patch", "merge"],
});
// With runtime state context
@@ -504,6 +518,27 @@ const contextPrompt = buildUserPrompt({
});
```
When `currentSpec` is provided, the prompt instructs the AI to use the specified edit modes. Available modes:
- **`"patch"`** — RFC 6902 JSON Patch. One operation per line. Best for precise, targeted single-field updates.
- **`"merge"`** — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes. Best for structural changes.
- **`"diff"`** — Unified diff against the serialized spec. Best for small text-level changes.
## Deep Merge and Diff
Format-agnostic utilities for working with specs:
```typescript
import { deepMergeSpec, diffToPatches } from "@json-render/core";
// RFC 7396 deep merge: null deletes, arrays replace, objects recurse
const merged = deepMergeSpec(baseSpec, { elements: { main: { props: { title: "New" } } } });
// RFC 6902 diff: generate JSON Patch operations from two objects
const patches = diffToPatches(oldSpec, newSpec);
// [{ op: "replace", path: "/elements/main/props/title", value: "New" }]
```
## Spec Validation
Validate spec structure and auto-fix common issues:
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/core",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [
+78
View File
@@ -0,0 +1,78 @@
import type { JsonPatch } from "./types";
/**
* Escape a single JSON Pointer token per RFC 6901.
* `~` → `~0`, `/` → `~1`.
*/
function escapeToken(token: string): string {
return token.replace(/~/g, "~0").replace(/\//g, "~1");
}
function buildPath(basePath: string, key: string): string {
return `${basePath}/${escapeToken(key)}`;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Shallow equality for arrays — used to avoid emitting patches when the
* children list hasn't actually changed.
*/
function arraysEqual(a: unknown[], b: unknown[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
/**
* Produce RFC 6902 JSON Patch operations that transform `oldObj` into `newObj`.
*
* - New keys → `add`
* - Changed scalar/array values → `replace`
* - Removed keys → `remove`
* - Arrays are compared shallowly and replaced atomically (not element-diffed)
* - Plain objects recurse
*/
export function diffToPatches(
oldObj: Record<string, unknown>,
newObj: Record<string, unknown>,
basePath = "",
): JsonPatch[] {
const patches: JsonPatch[] = [];
// Keys present in newObj
for (const key of Object.keys(newObj)) {
const path = buildPath(basePath, key);
const oldVal = oldObj[key];
const newVal = newObj[key];
if (!(key in oldObj)) {
patches.push({ op: "add", path, value: newVal });
continue;
}
// Both exist — compare
if (isPlainObject(oldVal) && isPlainObject(newVal)) {
patches.push(...diffToPatches(oldVal, newVal, path));
} else if (Array.isArray(oldVal) && Array.isArray(newVal)) {
if (!arraysEqual(oldVal, newVal)) {
patches.push({ op: "replace", path, value: newVal });
}
} else if (oldVal !== newVal) {
patches.push({ op: "replace", path, value: newVal });
}
}
// Keys removed from oldObj
for (const key of Object.keys(oldObj)) {
if (!(key in newObj)) {
patches.push({ op: "remove", path: buildPath(basePath, key) });
}
}
return patches;
}
+316
View File
@@ -0,0 +1,316 @@
import type { Spec } from "./types";
/**
* Edit mode for modifying an existing spec.
*
* - `"patch"` — RFC 6902 JSON Patch. One operation per line.
* - `"merge"` — RFC 7396 JSON Merge Patch. Partial object deep-merged; `null` deletes.
* - `"diff"` — Unified diff (POSIX). Line-level text edits against the serialized spec.
*/
export type EditMode = "patch" | "merge" | "diff";
export interface EditConfig {
/** Which edit modes are enabled. When >1, the AI chooses per edit. */
modes: EditMode[];
}
const DEFAULT_MODES: EditMode[] = ["patch"];
function normalizeModes(config?: EditConfig): EditMode[] {
if (!config?.modes?.length) return DEFAULT_MODES;
return config.modes;
}
// ── JSON-format instructions ──
function jsonPatchInstructions(): string {
return [
"PATCH MODE (RFC 6902 JSON Patch):",
"Output one JSON object per line. Each line is a patch operation.",
'- Add: {"op":"add","path":"/elements/new-key","value":{...}}',
'- Replace: {"op":"replace","path":"/elements/existing-key","value":{...}}',
'- Remove: {"op":"remove","path":"/elements/old-key"}',
"Only output patches for what needs to change.",
].join("\n");
}
function jsonMergeInstructions(): string {
return [
"MERGE MODE (RFC 7396 JSON Merge Patch):",
"Output a single JSON object on one line with __json_edit set to true.",
"Include only the keys that changed. Unmentioned keys are preserved.",
"Set a key to null to delete it.",
"",
"Example (update a title and add an element):",
'{"__json_edit":true,"elements":{"main":{"props":{"title":"New Title"}},"new-el":{"type":"Card","props":{},"children":[]}}}',
"",
"Example (delete an element):",
'{"__json_edit":true,"elements":{"old-widget":null}}',
].join("\n");
}
function jsonDiffInstructions(): string {
return [
"DIFF MODE (unified diff):",
"Output a unified diff inside a ```diff code fence.",
"The diff applies against the JSON-serialized current spec.",
"",
"Example:",
"```diff",
"--- a/spec.json",
"+++ b/spec.json",
"@@ -3,1 +3,1 @@",
'- "title": "Login"',
'+ "title": "Welcome Back"',
"```",
].join("\n");
}
// ── YAML-format instructions ──
function yamlPatchInstructions(): string {
return [
"PATCH MODE (RFC 6902 JSON Patch):",
"Output RFC 6902 JSON Patch lines inside a ```yaml-patch code fence.",
"Each line is one JSON patch operation.",
"",
"Example:",
"```yaml-patch",
'{"op":"replace","path":"/elements/main/props/title","value":"New Title"}',
'{"op":"add","path":"/elements/new-el","value":{"type":"Card","props":{},"children":[]}}',
"```",
].join("\n");
}
function yamlMergeInstructions(): string {
return [
"MERGE MODE (RFC 7396 JSON Merge Patch):",
"Output only the changed parts in a ```yaml-edit code fence.",
"Uses deep merge semantics: only keys you include are updated. Unmentioned elements and props are preserved.",
"Set a key to null to delete it.",
"",
"Example edit (update title, add a new element):",
"```yaml-edit",
"elements:",
" main:",
" props:",
" title: Updated Title",
" new-chart:",
" type: Card",
" props: {}",
" children: []",
"```",
"",
"Example deletion:",
"```yaml-edit",
"elements:",
" old-widget: null",
"```",
].join("\n");
}
function yamlDiffInstructions(): string {
return [
"DIFF MODE (unified diff):",
"Output a unified diff inside a ```diff code fence.",
"The diff applies against the YAML-serialized current spec.",
"",
"Example:",
"```diff",
"--- a/spec.yaml",
"+++ b/spec.yaml",
"@@ -6,1 +6,1 @@",
"- title: Login",
"+ title: Welcome Back",
"```",
].join("\n");
}
// ── Mode selection guidance ──
function modeSelectionGuidance(modes: EditMode[]): string {
if (modes.length === 1) return "";
const parts = ["Choose the best edit strategy for the requested change:"];
if (modes.includes("patch")) {
parts.push("- PATCH: best for precise, targeted single-field updates");
}
if (modes.includes("merge")) {
parts.push(
"- MERGE: best for structural changes (add/remove elements, reparent children, update multiple props at once)",
);
}
if (modes.includes("diff")) {
parts.push(
"- DIFF: best for small text-level changes when you can see the exact lines to change",
);
}
return parts.join("\n");
}
/**
* Generate the prompt section describing available edit modes.
* Only documents the modes that are enabled.
*/
export function buildEditInstructions(
config: EditConfig | undefined,
format: "json" | "yaml",
): string {
const modes = normalizeModes(config);
const sections: string[] = [];
sections.push("EDITING EXISTING SPECS:");
sections.push("");
const guidance = modeSelectionGuidance(modes);
if (guidance) {
sections.push(guidance);
sections.push("");
}
for (const mode of modes) {
if (format === "json") {
switch (mode) {
case "patch":
sections.push(jsonPatchInstructions());
break;
case "merge":
sections.push(jsonMergeInstructions());
break;
case "diff":
sections.push(jsonDiffInstructions());
break;
}
} else {
switch (mode) {
case "patch":
sections.push(yamlPatchInstructions());
break;
case "merge":
sections.push(yamlMergeInstructions());
break;
case "diff":
sections.push(yamlDiffInstructions());
break;
}
}
sections.push("");
}
return sections.join("\n");
}
function addLineNumbers(text: string): string {
const lines = text.split("\n");
const width = String(lines.length).length;
return lines
.map((line, i) => `${String(i + 1).padStart(width)}| ${line}`)
.join("\n");
}
export function isNonEmptySpec(spec: unknown): spec is Spec {
if (!spec || typeof spec !== "object") return false;
const s = spec as Record<string, unknown>;
return (
typeof s.root === "string" &&
typeof s.elements === "object" &&
s.elements !== null &&
Object.keys(s.elements as object).length > 0
);
}
export interface BuildEditUserPromptOptions {
prompt: string;
currentSpec?: Spec | null;
config?: EditConfig;
format: "json" | "yaml";
maxPromptLength?: number;
/** Serialise the spec. Defaults to JSON.stringify for json, must be provided for yaml. */
serializer?: (spec: Spec) => string;
}
/**
* Generate the user prompt for edits, including the current spec
* (with line numbers when diff mode is enabled) and mode instructions.
*/
export function buildEditUserPrompt(
options: BuildEditUserPromptOptions,
): string {
const { prompt, currentSpec, config, format, maxPromptLength, serializer } =
options;
let userText = String(prompt || "");
if (maxPromptLength !== undefined && maxPromptLength > 0) {
userText = userText.slice(0, maxPromptLength);
}
if (!isNonEmptySpec(currentSpec)) {
return userText;
}
const modes = normalizeModes(config);
const showLineNumbers = modes.includes("diff");
const serialize = serializer ?? ((s: Spec) => JSON.stringify(s, null, 2));
const specText = serialize(currentSpec);
const parts: string[] = [];
if (showLineNumbers) {
parts.push("CURRENT UI STATE (line numbers for reference):");
parts.push("```");
parts.push(addLineNumbers(specText));
parts.push("```");
} else {
parts.push(
"CURRENT UI STATE (already loaded, DO NOT recreate existing elements):",
);
parts.push("```");
parts.push(specText);
parts.push("```");
}
parts.push("");
parts.push(`USER REQUEST: ${userText}`);
parts.push("");
if (modes.length === 1) {
const mode = modes[0]!;
switch (mode) {
case "patch":
parts.push(
format === "yaml"
? "Output ONLY the patches in a ```yaml-patch fence."
: "Output ONLY the JSON Patch lines needed for the change.",
);
break;
case "merge":
parts.push(
format === "yaml"
? "Output ONLY the changes in a ```yaml-edit fence. Include only keys that need to change."
: "Output ONLY a single JSON merge line with __json_edit set to true. Include only keys that need to change.",
);
break;
case "diff":
parts.push("Output ONLY the unified diff in a ```diff fence.");
break;
}
} else {
const modeNames = modes.map((m) => {
switch (m) {
case "patch":
return format === "yaml" ? "```yaml-patch fence" : "JSON Patch lines";
case "merge":
return format === "yaml"
? "```yaml-edit fence"
: "JSON merge line (__json_edit)";
case "diff":
return "```diff fence";
}
});
parts.push(
`Choose the best edit strategy and output using one of: ${modeNames.join(", ")}`,
);
}
return parts.join("\n");
}
+16
View File
@@ -178,3 +178,19 @@ export { defineCatalog } from "./schema";
export type { UserPromptOptions } from "./prompt";
export { buildUserPrompt } from "./prompt";
// Object diff & merge (format-agnostic)
export { deepMergeSpec } from "./merge";
export { diffToPatches } from "./diff";
// Edit modes
export type {
EditMode,
EditConfig,
BuildEditUserPromptOptions,
} from "./edit-modes";
export {
buildEditInstructions,
buildEditUserPrompt,
isNonEmptySpec,
} from "./edit-modes";
+41
View File
@@ -0,0 +1,41 @@
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Deep-merge `patch` into `base`, returning a new object.
*
* Semantics (RFC 7396 JSON Merge Patch):
* - `null` values in `patch` delete the corresponding key from `base`
* - Arrays in `patch` replace (not concat) the corresponding array in `base`
* - Plain objects recurse
* - All other values replace
*
* Neither `base` nor `patch` is mutated.
*/
export function deepMergeSpec(
base: Record<string, unknown>,
patch: Record<string, unknown>,
): Record<string, unknown> {
const result: Record<string, unknown> = { ...base };
for (const key of Object.keys(patch)) {
const patchVal = patch[key];
// null → delete
if (patchVal === null) {
delete result[key];
continue;
}
const baseVal = result[key];
if (isPlainObject(patchVal) && isPlainObject(baseVal)) {
result[key] = deepMergeSpec(baseVal, patchVal);
} else {
result[key] = patchVal;
}
}
return result;
}
+16 -41
View File
@@ -1,4 +1,6 @@
import type { Spec } from "./types";
import type { EditMode } from "./edit-modes";
import { buildEditUserPrompt, isNonEmptySpec } from "./edit-modes";
/**
* Options for building a user prompt.
@@ -12,37 +14,16 @@ export interface UserPromptOptions {
state?: Record<string, unknown> | null;
/** Maximum length for the user's text prompt (applied before wrapping) */
maxPromptLength?: number;
/** Edit modes to offer when refining an existing spec. Default: `["patch"]`. */
editModes?: EditMode[];
}
/**
* Check whether a spec is non-empty (has a root and at least one element).
*/
function isNonEmptySpec(spec: unknown): spec is Spec {
if (!spec || typeof spec !== "object") return false;
const s = spec as Record<string, unknown>;
return (
typeof s.root === "string" &&
typeof s.elements === "object" &&
s.elements !== null &&
Object.keys(s.elements as object).length > 0
);
}
const PATCH_INSTRUCTIONS = `IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
- To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
- To remove an element: {"op":"remove","path":"/elements/old-key"}
- To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
- To add children: update the parent element with new children array
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
/**
* Build a user prompt for AI generation.
*
* Handles common patterns that every consuming app needs:
* - Truncating the user's prompt to a max length
* - Including the current spec for refinement (patch-only mode)
* - Including the current spec for refinement (edit mode)
* - Including runtime state context
*
* @example
@@ -53,12 +34,12 @@ DO NOT output patches for elements that don't need to change. Only output what's
* // Refinement with existing spec
* buildUserPrompt({ prompt: "add a dark mode toggle", currentSpec: spec })
*
* // With state context
* buildUserPrompt({ prompt: "show my data", state: { todos: [] } })
* // With multiple edit modes
* buildUserPrompt({ prompt: "change title", currentSpec: spec, editModes: ["patch", "merge"] })
* ```
*/
export function buildUserPrompt(options: UserPromptOptions): string {
const { prompt, currentSpec, state, maxPromptLength } = options;
const { prompt, currentSpec, state, maxPromptLength, editModes } = options;
// Sanitize and optionally truncate the user's text
let userText = String(prompt || "");
@@ -68,25 +49,19 @@ export function buildUserPrompt(options: UserPromptOptions): string {
// --- Refinement mode: currentSpec is provided ---
if (isNonEmptySpec(currentSpec)) {
const parts: string[] = [];
parts.push(
`CURRENT UI STATE (already loaded, DO NOT recreate existing elements):`,
);
parts.push(JSON.stringify(currentSpec, null, 2));
parts.push("");
parts.push(`USER REQUEST: ${userText}`);
const editPrompt = buildEditUserPrompt({
prompt: userText,
currentSpec,
config: { modes: editModes ?? ["patch"] },
format: "json",
});
// Append state context if provided
if (state && Object.keys(state).length > 0) {
parts.push("");
parts.push(`AVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`);
return `${editPrompt}\n\nAVAILABLE STATE:\n${JSON.stringify(state, null, 2)}`;
}
parts.push("");
parts.push(PATCH_INSTRUCTIONS);
return parts.join("\n");
return editPrompt;
}
// --- Fresh generation mode ---
+10
View File
@@ -1,4 +1,6 @@
import { z } from "zod";
import type { EditMode } from "./edit-modes";
import { buildEditInstructions } from "./edit-modes";
/**
* Schema builder primitives
@@ -142,6 +144,8 @@ export interface PromptOptions {
* @deprecated `"chat"` — use `"inline"` instead.
*/
mode?: "standalone" | "inline" | "generate" | "chat";
/** Edit modes to document in the system prompt. Default: `["patch"]`. */
editModes?: EditMode[];
}
/**
@@ -1049,6 +1053,12 @@ Note: state patches appear right after the elements that use them, so the UI fil
lines.push("");
}
// Edit modes
const editModes = options.editModes;
if (editModes && editModes.length > 0) {
lines.push(buildEditInstructions({ modes: editModes }, "json"));
}
// Rules
lines.push("RULES:");
const baseRules =
+7
View File
@@ -1,5 +1,12 @@
# @json-render/image
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/image",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Image renderer for @json-render/core. JSON becomes SVG and PNG images via Satori.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/jotai
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/jotai",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Jotai adapter for json-render StateStore",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/mcp
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/mcp",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "MCP Apps integration for @json-render/core. Serve json-render UIs as interactive MCP Apps in Claude, ChatGPT, Cursor, and VS Code.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-email
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-email",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "React Email renderer for @json-render/core. JSON becomes HTML emails.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-native
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-native",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "React Native renderer for @json-render/core. JSON becomes React Native components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react-pdf
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-pdf",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "React PDF renderer for @json-render/core. JSON becomes PDF documents.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @internal/react-state
## 0.8.8
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.8.7
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@internal/react-state",
"version": "0.8.7",
"version": "0.8.8",
"private": true,
"license": "Apache-2.0",
"description": "Shared React state context for json-render renderer packages",
+8
View File
@@ -1,5 +1,13 @@
# @json-render/react-three-fiber
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
## 0.13.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react-three-fiber",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "React Three Fiber renderer for @json-render/core. JSON becomes 3D scenes.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/react
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/react",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/redux
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/redux",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Redux adapter for json-render StateStore",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/remotion
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/remotion",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [
+8
View File
@@ -1,5 +1,13 @@
# @json-render/shadcn
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
- @json-render/react@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/shadcn",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "shadcn/ui component library for @json-render/core. JSON becomes beautiful Tailwind-styled React components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/solid
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Minor Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/solid",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "SolidJS renderer for @json-render/core. JSON becomes Solid components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/svelte
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/svelte",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Svelte 5 renderer for @json-render/core. JSON becomes Svelte components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/vue
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/vue",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "Vue renderer for @json-render/core. JSON becomes Vue components.",
"keywords": [
+7
View File
@@ -1,5 +1,12 @@
# @json-render/xstate
## 0.14.0
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
## 0.13.0
### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@json-render/xstate",
"version": "0.13.0",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "XState Store adapter for json-render StateStore",
"keywords": [
+21
View File
@@ -0,0 +1,21 @@
# @json-render/yaml
## 0.14.0
### Minor Changes
- a8afd8b: Add YAML wire format package and universal edit modes for surgical spec refinement.
### New:
- **`@json-render/yaml`** -- YAML wire format for json-render. Includes streaming YAML parser, `yamlPrompt()` for system prompts, and AI SDK transform (`pipeYamlRender`) as a drop-in alternative to JSONL streaming. Supports four fence types: `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff`.
- **Universal edit modes** in `@json-render/core` -- three strategies for multi-turn spec refinement: `patch` (RFC 6902), `merge` (RFC 7396), and `diff` (unified diff). New `editModes` option on `buildUserPrompt()` and `PromptOptions`. New helpers: `deepMergeSpec()`, `diffToPatches()`, `buildEditUserPrompt()`, `buildEditInstructions()`, `isNonEmptySpec()`.
### Improved:
- **Playground** -- format toggle (JSONL / YAML), edit mode picker (patch / merge / diff), and token usage display with prompt caching stats.
- **Prompt caching** -- generate API uses Anthropic ephemeral cache control for system prompts.
- **CI** -- lint, type-check, and test jobs now run in parallel.
### Patch Changes
- Updated dependencies [a8afd8b]
- @json-render/core@0.14.0
+113
View File
@@ -0,0 +1,113 @@
# @json-render/yaml
YAML wire format for `@json-render/core`. Progressive rendering and surgical edits via streaming YAML.
## Installation
```bash
npm install @json-render/yaml @json-render/core yaml
```
## Key Concepts
- **YAML wire format**: Uses `yaml-spec`, `yaml-edit`, `yaml-patch`, and `diff` code fences instead of JSONL
- **Streaming parser**: Incrementally parses YAML as it arrives, emitting JSON Patch operations
- **Edit modes**: Supports patch (RFC 6902), merge (RFC 7396), and unified diff for surgical edits
- **AI SDK transform**: Drop-in `TransformStream` that converts YAML fences into json-render patch data parts
## Quick Start
### Generate a YAML System Prompt
```typescript
import { yamlPrompt } from "@json-render/yaml";
import { catalog } from "./catalog";
const systemPrompt = yamlPrompt(catalog, {
mode: "standalone",
editModes: ["merge"],
});
```
### Stream YAML Specs (AI SDK)
```typescript
import { pipeYamlRender } from "@json-render/yaml";
import { createUIMessageStream, createUIMessageStreamResponse } from "ai";
const stream = createUIMessageStream({
execute: async ({ writer }) => {
writer.merge(pipeYamlRender(result.toUIMessageStream()));
},
});
return createUIMessageStreamResponse({ stream });
```
### Streaming Parser (Low-Level)
```typescript
import { createYamlStreamCompiler } from "@json-render/yaml";
const compiler = createYamlStreamCompiler<Spec>();
// Feed chunks as they arrive
const { result, newPatches } = compiler.push("root: main\n");
compiler.push("elements:\n main:\n type: Card\n");
// Flush remaining data
const { result: final } = compiler.flush();
```
## API Reference
### `yamlPrompt(catalog, options?)`
Generate a YAML-format system prompt from any json-render catalog.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `system` | `string` | `"You are a UI generator that outputs YAML."` | Custom system message intro |
| `mode` | `"standalone" \| "inline"` | `"standalone"` | Output mode |
| `customRules` | `string[]` | `[]` | Additional rules |
| `editModes` | `EditMode[]` | `["merge"]` | Edit modes to document |
### `createYamlTransform(options?)`
Creates a `TransformStream` that converts YAML spec/edit fences in AI SDK stream chunks into json-render patch data parts.
| Option | Type | Description |
|--------|------|-------------|
| `previousSpec` | `Spec` | Seed with a previous spec for multi-turn edit support |
### `pipeYamlRender(stream, options?)`
Convenience wrapper that pipes an AI SDK stream through the YAML transform. Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
### `createYamlStreamCompiler(initial?)`
Create a streaming YAML compiler that incrementally parses YAML text and emits JSON Patch operations.
**Returns** `YamlStreamCompiler<T>` with methods:
| Method | Description |
|--------|-------------|
| `push(chunk)` | Push a chunk of text. Returns `{ result, newPatches }` |
| `flush()` | Flush remaining buffer and return final result |
| `getResult()` | Get the current compiled result |
| `getPatches()` | Get all patches applied so far |
| `reset(initial?)` | Reset to initial state |
### Fence Constants
Exported constants for fence detection:
- `YAML_SPEC_FENCE` — `` ```yaml-spec ``
- `YAML_EDIT_FENCE` — `` ```yaml-edit ``
- `YAML_PATCH_FENCE` — `` ```yaml-patch ``
- `DIFF_FENCE` — `` ```diff ``
- `FENCE_CLOSE` — `` ``` ``
### Re-exports from `@json-render/core`
- `diffToPatches(oldObj, newObj)` — Generate RFC 6902 JSON Patch from object diff
- `deepMergeSpec(base, patch)` — RFC 7396 JSON Merge Patch
+59
View File
@@ -0,0 +1,59 @@
{
"name": "@json-render/yaml",
"version": "0.14.0",
"license": "Apache-2.0",
"description": "YAML wire format for @json-render/core. Progressive rendering and surgical edits via streaming YAML.",
"keywords": [
"json",
"yaml",
"ui",
"ai",
"generative-ui",
"llm",
"streaming",
"progressive-rendering"
],
"repository": {
"type": "git",
"url": "git+https://github.com/vercel-labs/json-render.git",
"directory": "packages/yaml"
},
"homepage": "https://json-render.dev",
"bugs": {
"url": "https://github.com/vercel-labs/json-render/issues"
},
"publishConfig": {
"access": "public"
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@json-render/core": "workspace:*",
"diff": "^8.0.3",
"yaml": "^2.8.2"
},
"devDependencies": {
"@internal/typescript-config": "workspace:*",
"tsup": "^8.0.2",
"typescript": "^5.4.5",
"vitest": "^4.0.17",
"zod": "^4.3.6"
}
}
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { diffToPatches } from "./diff";
describe("diffToPatches", () => {
it("returns empty array for identical objects", () => {
const obj = { a: 1, b: "hello" };
expect(diffToPatches(obj, obj)).toEqual([]);
});
it("detects added keys", () => {
const patches = diffToPatches({}, { name: "Alice" });
expect(patches).toEqual([{ op: "add", path: "/name", value: "Alice" }]);
});
it("detects removed keys", () => {
const patches = diffToPatches({ name: "Alice" }, {});
expect(patches).toEqual([{ op: "remove", path: "/name" }]);
});
it("detects changed scalar values", () => {
const patches = diffToPatches({ name: "Alice" }, { name: "Bob" });
expect(patches).toEqual([{ op: "replace", path: "/name", value: "Bob" }]);
});
it("recurses into nested objects", () => {
const oldObj = { user: { name: "Alice", age: 30 } };
const newObj = { user: { name: "Alice", age: 31 } };
const patches = diffToPatches(oldObj, newObj);
expect(patches).toEqual([{ op: "replace", path: "/user/age", value: 31 }]);
});
it("replaces arrays atomically", () => {
const oldObj = { items: ["a", "b"] };
const newObj = { items: ["a", "b", "c"] };
const patches = diffToPatches(oldObj, newObj);
expect(patches).toEqual([
{ op: "replace", path: "/items", value: ["a", "b", "c"] },
]);
});
it("does not emit patch for identical arrays", () => {
const oldObj = { items: [1, 2, 3] };
const newObj = { items: [1, 2, 3] };
expect(diffToPatches(oldObj, newObj)).toEqual([]);
});
it("handles type changes (object → scalar)", () => {
const oldObj = { data: { nested: true } };
const newObj = { data: "flat" };
const patches = diffToPatches(
oldObj as Record<string, unknown>,
newObj as Record<string, unknown>,
);
expect(patches).toEqual([{ op: "replace", path: "/data", value: "flat" }]);
});
it("handles a complex spec diff", () => {
const oldSpec = {
root: "main",
elements: {
main: { type: "Card", props: { title: "Hello" }, children: [] },
},
};
const newSpec = {
root: "main",
elements: {
main: {
type: "Card",
props: { title: "Hello" },
children: ["child-1"],
},
"child-1": {
type: "Text",
props: { content: "World" },
children: [],
},
},
};
const patches = diffToPatches(oldSpec, newSpec);
expect(patches).toContainEqual({
op: "replace",
path: "/elements/main/children",
value: ["child-1"],
});
expect(patches).toContainEqual({
op: "add",
path: "/elements/child-1",
value: {
type: "Text",
props: { content: "World" },
children: [],
},
});
});
it("escapes JSON Pointer tokens (~ and /)", () => {
const patches = diffToPatches({}, { "a/b": 1, "c~d": 2 });
expect(patches).toContainEqual({
op: "add",
path: "/a~1b",
value: 1,
});
expect(patches).toContainEqual({
op: "add",
path: "/c~0d",
value: 2,
});
});
});
+1
View File
@@ -0,0 +1 @@
export { diffToPatches } from "@json-render/core";
+24
View File
@@ -0,0 +1,24 @@
// Diff
export { diffToPatches } from "./diff";
// Merge
export { deepMergeSpec } from "./merge";
// Streaming YAML compiler
export type { YamlStreamCompiler } from "./parser";
export { createYamlStreamCompiler } from "./parser";
// AI SDK transform
export {
createYamlTransform,
pipeYamlRender,
YAML_SPEC_FENCE,
YAML_EDIT_FENCE,
YAML_PATCH_FENCE,
DIFF_FENCE,
FENCE_CLOSE,
} from "./transform";
// Prompt generation
export type { YamlPromptOptions } from "./prompt";
export { yamlPrompt } from "./prompt";
+109
View File
@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import { deepMergeSpec } from "./merge";
describe("deepMergeSpec", () => {
it("adds new keys", () => {
const result = deepMergeSpec({ a: 1 }, { b: 2 });
expect(result).toEqual({ a: 1, b: 2 });
});
it("replaces scalar values", () => {
const result = deepMergeSpec({ a: 1 }, { a: 2 });
expect(result).toEqual({ a: 2 });
});
it("deletes keys set to null", () => {
const result = deepMergeSpec({ a: 1, b: 2 }, { b: null });
expect(result).toEqual({ a: 1 });
});
it("deep-merges nested objects", () => {
const base = { user: { name: "Alice", age: 30 } };
const patch = { user: { age: 31 } };
const result = deepMergeSpec(base, patch);
expect(result).toEqual({ user: { name: "Alice", age: 31 } });
});
it("replaces arrays (does not concat)", () => {
const base = { items: [1, 2, 3] };
const patch = { items: [4, 5] };
const result = deepMergeSpec(base, patch);
expect(result).toEqual({ items: [4, 5] });
});
it("does not mutate base or patch", () => {
const base = { a: { b: 1 } };
const patch = { a: { c: 2 } };
const baseCopy = JSON.parse(JSON.stringify(base));
const patchCopy = JSON.parse(JSON.stringify(patch));
deepMergeSpec(base, patch);
expect(base).toEqual(baseCopy);
expect(patch).toEqual(patchCopy);
});
it("handles a spec-like edit merge", () => {
const base = {
root: "main",
elements: {
main: {
type: "Card",
props: { title: "Dashboard" },
children: ["metric-1"],
},
"metric-1": {
type: "Metric",
props: { label: "Revenue", value: "$1M" },
children: [],
},
},
};
const patch = {
elements: {
main: {
props: { title: "Updated Dashboard" },
children: ["metric-1", "chart-1"],
},
"chart-1": {
type: "Chart",
props: { data: "revenue" },
children: [],
},
},
};
const result = deepMergeSpec(base, patch);
expect(result.root).toBe("main");
expect(
(result.elements as Record<string, Record<string, unknown>>)["main"],
).toEqual({
type: "Card",
props: { title: "Updated Dashboard" },
children: ["metric-1", "chart-1"],
});
expect(
(result.elements as Record<string, Record<string, unknown>>)["metric-1"],
).toEqual({
type: "Metric",
props: { label: "Revenue", value: "$1M" },
children: [],
});
expect(
(result.elements as Record<string, Record<string, unknown>>)["chart-1"],
).toEqual({
type: "Chart",
props: { data: "revenue" },
children: [],
});
});
it("deletes an element via null", () => {
const base = {
elements: {
main: { type: "Card" },
old: { type: "Widget" },
},
};
const patch = { elements: { old: null } };
const result = deepMergeSpec(base, patch);
expect(result.elements).toEqual({ main: { type: "Card" } });
});
});
+1
View File
@@ -0,0 +1 @@
export { deepMergeSpec } from "@json-render/core";

Some files were not shown because too many files have changed in this diff Show More