mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-02 12:00:58 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebab5632c8 | ||
|
|
495195798e | ||
|
|
b54c4d0610 | ||
|
|
f77a013464 | ||
|
|
124e876f51 |
@@ -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": [],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
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: [
|
||||
"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.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"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 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.",
|
||||
"For content-heavy UIs (blogs, dashboards, product listings): use Stack or Grid as root. Use Grid with 2-3 columns for card layouts.",
|
||||
"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({
|
||||
prompt,
|
||||
currentSpec: context?.previousSpec,
|
||||
maxPromptLength: MAX_PROMPT_LENGTH,
|
||||
});
|
||||
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();
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<PlaygroundControls
|
||||
format={format}
|
||||
setFormat={setFormat}
|
||||
editModes={editModes}
|
||||
setEditModes={setEditModes}
|
||||
showClear={versions.length > 0}
|
||||
onClear={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
{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={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
clear();
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<PlaygroundControls
|
||||
format={format}
|
||||
setFormat={setFormat}
|
||||
editModes={editModes}
|
||||
setEditModes={setEditModes}
|
||||
showClear={versions.length > 0}
|
||||
onClear={() => {
|
||||
setVersions([]);
|
||||
setSelectedVersionId(null);
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
+36
-1
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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 ---
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@json-render/yaml",
|
||||
"version": "0.13.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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { diffToPatches } from "@json-render/core";
|
||||
@@ -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";
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { deepMergeSpec } from "@json-render/core";
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { createYamlStreamCompiler } from "./parser";
|
||||
|
||||
describe("createYamlStreamCompiler", () => {
|
||||
it("parses a simple YAML document incrementally", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
const r1 = compiler.push("root: main\n");
|
||||
expect(r1.newPatches.length).toBeGreaterThan(0);
|
||||
expect(r1.result).toHaveProperty("root", "main");
|
||||
});
|
||||
|
||||
it("accumulates elements as lines arrive", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
compiler.push("root: main\n");
|
||||
compiler.push("elements:\n");
|
||||
compiler.push(" main:\n");
|
||||
compiler.push(" type: Card\n");
|
||||
|
||||
const { result } = compiler.flush();
|
||||
expect(result).toHaveProperty("root", "main");
|
||||
|
||||
const elements = result.elements as Record<string, Record<string, unknown>>;
|
||||
expect(elements.main).toBeDefined();
|
||||
expect(elements.main!.type).toBe("Card");
|
||||
});
|
||||
|
||||
it("emits patches only for changes", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
const r1 = compiler.push("root: main\n");
|
||||
expect(r1.newPatches).toEqual([
|
||||
{ op: "add", path: "/root", value: "main" },
|
||||
]);
|
||||
|
||||
// Pushing the same content again (no new complete line) should not emit patches
|
||||
const r2 = compiler.push("");
|
||||
expect(r2.newPatches).toEqual([]);
|
||||
});
|
||||
|
||||
it("tracks all patches via getPatches()", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
compiler.push("a: 1\n");
|
||||
compiler.push("b: 2\n");
|
||||
|
||||
const allPatches = compiler.getPatches();
|
||||
expect(allPatches.length).toBe(2);
|
||||
expect(allPatches[0]).toEqual({ op: "add", path: "/a", value: 1 });
|
||||
expect(allPatches[1]).toEqual({ op: "add", path: "/b", value: 2 });
|
||||
});
|
||||
|
||||
it("resets to initial state", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
compiler.push("root: main\n");
|
||||
expect(compiler.getResult()).toHaveProperty("root", "main");
|
||||
|
||||
compiler.reset();
|
||||
expect(compiler.getResult()).toEqual({});
|
||||
expect(compiler.getPatches()).toEqual([]);
|
||||
});
|
||||
|
||||
it("resets with initial value and diffs from it", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
compiler.reset({ root: "existing", elements: {} });
|
||||
|
||||
// The YAML includes root, so the initial value is preserved in the diff base
|
||||
const { newPatches } = compiler.push(
|
||||
"root: existing\nelements:\n main:\n type: Card\n",
|
||||
);
|
||||
const { result } = compiler.flush();
|
||||
|
||||
expect(result).toHaveProperty("root", "existing");
|
||||
expect(result).toHaveProperty("elements");
|
||||
// Only the new element should be patched, not root (unchanged)
|
||||
expect(newPatches.find((p) => p.path === "/root")).toBeUndefined();
|
||||
expect(newPatches.find((p) => p.path === "/elements/main")).toBeDefined();
|
||||
});
|
||||
|
||||
it("handles a full spec YAML", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
const yaml = [
|
||||
"root: main\n",
|
||||
"elements:\n",
|
||||
" main:\n",
|
||||
" type: Card\n",
|
||||
" props:\n",
|
||||
" title: Dashboard\n",
|
||||
" children:\n",
|
||||
" - metric-1\n",
|
||||
" metric-1:\n",
|
||||
" type: Metric\n",
|
||||
" props:\n",
|
||||
" label: Revenue\n",
|
||||
' value: "$1.2M"\n',
|
||||
" children: []\n",
|
||||
"state:\n",
|
||||
" revenue: 1200000\n",
|
||||
];
|
||||
|
||||
for (const line of yaml) {
|
||||
compiler.push(line);
|
||||
}
|
||||
const { result } = compiler.flush();
|
||||
|
||||
expect(result.root).toBe("main");
|
||||
expect(result.state).toEqual({ revenue: 1200000 });
|
||||
|
||||
const elements = result.elements as Record<string, Record<string, unknown>>;
|
||||
expect(elements.main).toBeDefined();
|
||||
expect(elements["metric-1"]).toBeDefined();
|
||||
expect((elements["metric-1"]!.props as Record<string, unknown>).label).toBe(
|
||||
"Revenue",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not crash on invalid YAML mid-stream", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
// Partial YAML that won't parse
|
||||
compiler.push("elements:\n");
|
||||
compiler.push(" main:\n");
|
||||
compiler.push(" type: "); // incomplete value — no newline yet
|
||||
|
||||
// Should not throw, result should still be from last successful parse
|
||||
const r = compiler.push("\n");
|
||||
expect(r.result).toBeDefined();
|
||||
});
|
||||
|
||||
it("YAML 1.2 does not coerce yes/no/on/off to booleans", () => {
|
||||
const compiler = createYamlStreamCompiler();
|
||||
|
||||
compiler.push("active: yes\n");
|
||||
compiler.push("disabled: no\n");
|
||||
compiler.push("on_value: on\n");
|
||||
compiler.push("off_value: off\n");
|
||||
|
||||
const { result } = compiler.flush();
|
||||
// YAML 1.2 (yaml v2 default) treats these as strings, not booleans
|
||||
expect(result.active).toBe("yes");
|
||||
expect(result.disabled).toBe("no");
|
||||
expect(result.on_value).toBe("on");
|
||||
expect(result.off_value).toBe("off");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { parse } from "yaml";
|
||||
import type { JsonPatch } from "@json-render/core";
|
||||
import { diffToPatches } from "./diff";
|
||||
|
||||
/**
|
||||
* Streaming YAML compiler that incrementally parses YAML text and emits
|
||||
* JSON Patch operations for each change detected.
|
||||
*
|
||||
* Same interface shape as `SpecStreamCompiler` from `@json-render/core`.
|
||||
*/
|
||||
export interface YamlStreamCompiler<T> {
|
||||
/** Push a chunk of text. Returns the current result and any new patches. */
|
||||
push(chunk: string): { result: T; newPatches: JsonPatch[] };
|
||||
/** Flush remaining buffer and return the final result. */
|
||||
flush(): { result: T; newPatches: JsonPatch[] };
|
||||
/** Get the current compiled result. */
|
||||
getResult(): T;
|
||||
/** Get all patches that have been applied. */
|
||||
getPatches(): JsonPatch[];
|
||||
/** Reset the compiler to initial state. */
|
||||
reset(initial?: Partial<T>): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a streaming YAML compiler.
|
||||
*
|
||||
* Incrementally parses YAML text as it arrives and emits JSON Patch
|
||||
* operations by diffing each successful parse against the previous snapshot.
|
||||
*
|
||||
* Uses `yaml.parse()` with YAML 1.2 defaults (the `yaml` v2 default).
|
||||
* YAML 1.2 does not coerce `yes`/`no`/`on`/`off` to booleans.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const compiler = createYamlStreamCompiler<Spec>();
|
||||
* compiler.push("root: main\n");
|
||||
* compiler.push("elements:\n main:\n type: Card\n");
|
||||
* const { result } = compiler.flush();
|
||||
* ```
|
||||
*/
|
||||
export function createYamlStreamCompiler<
|
||||
T extends Record<string, unknown> = Record<string, unknown>,
|
||||
>(initial?: Partial<T>): YamlStreamCompiler<T> {
|
||||
let accumulated = "";
|
||||
let snapshot: Record<string, unknown> = initial
|
||||
? { ...initial }
|
||||
: ({} as Record<string, unknown>);
|
||||
let result: T = { ...snapshot } as T;
|
||||
const allPatches: JsonPatch[] = [];
|
||||
|
||||
function tryParse(): { result: T; newPatches: JsonPatch[] } {
|
||||
const newPatches: JsonPatch[] = [];
|
||||
|
||||
try {
|
||||
const parsed = parse(accumulated);
|
||||
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
const patches = diffToPatches(
|
||||
snapshot,
|
||||
parsed as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (patches.length > 0) {
|
||||
snapshot = structuredClone(parsed as Record<string, unknown>);
|
||||
result = { ...snapshot } as T;
|
||||
allPatches.push(...patches);
|
||||
newPatches.push(...patches);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Incomplete YAML — wait for more data
|
||||
}
|
||||
|
||||
return { result, newPatches };
|
||||
}
|
||||
|
||||
return {
|
||||
push(chunk: string): { result: T; newPatches: JsonPatch[] } {
|
||||
accumulated += chunk;
|
||||
|
||||
// Only attempt parse when we have a complete line
|
||||
if (chunk.includes("\n")) {
|
||||
return tryParse();
|
||||
}
|
||||
|
||||
return { result, newPatches: [] };
|
||||
},
|
||||
|
||||
flush(): { result: T; newPatches: JsonPatch[] } {
|
||||
return tryParse();
|
||||
},
|
||||
|
||||
getResult(): T {
|
||||
return result;
|
||||
},
|
||||
|
||||
getPatches(): JsonPatch[] {
|
||||
return [...allPatches];
|
||||
},
|
||||
|
||||
reset(newInitial?: Partial<T>): void {
|
||||
accumulated = "";
|
||||
snapshot = newInitial
|
||||
? { ...newInitial }
|
||||
: ({} as Record<string, unknown>);
|
||||
result = { ...snapshot } as T;
|
||||
allPatches.length = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { defineSchema, defineCatalog } from "@json-render/core";
|
||||
import { z } from "zod";
|
||||
import { yamlPrompt } from "./prompt";
|
||||
|
||||
const testSchema = defineSchema(
|
||||
(s) => ({
|
||||
spec: s.object({
|
||||
root: s.string(),
|
||||
elements: s.record(
|
||||
s.object({
|
||||
type: s.ref("catalog.components"),
|
||||
props: s.propsOf("catalog.components"),
|
||||
children: s.array(s.string()),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
catalog: s.object({
|
||||
components: s.map({
|
||||
props: s.zod(),
|
||||
description: s.string(),
|
||||
}),
|
||||
actions: s.map({
|
||||
description: s.string(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
builtInActions: [{ name: "setState", description: "Set a state value" }],
|
||||
},
|
||||
);
|
||||
|
||||
const testCatalog = defineCatalog(testSchema, {
|
||||
components: {
|
||||
Card: {
|
||||
props: z.object({ title: z.string() }),
|
||||
description: "A card container",
|
||||
},
|
||||
Text: {
|
||||
props: z.object({ content: z.string() }),
|
||||
description: "Display text",
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
refresh: { description: "Refresh data" },
|
||||
},
|
||||
});
|
||||
|
||||
describe("yamlPrompt", () => {
|
||||
it("generates a prompt string", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(typeof prompt).toBe("string");
|
||||
expect(prompt.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("includes YAML format instructions", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(prompt).toContain("YAML");
|
||||
expect(prompt).toContain("yaml-spec");
|
||||
});
|
||||
|
||||
it("includes component names from the catalog", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(prompt).toContain("Card");
|
||||
expect(prompt).toContain("Text");
|
||||
});
|
||||
|
||||
it("includes action names", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(prompt).toContain("refresh");
|
||||
expect(prompt).toContain("setState");
|
||||
});
|
||||
|
||||
it("includes yaml-edit instructions", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(prompt).toContain("yaml-edit");
|
||||
expect(prompt).toContain("deep merge");
|
||||
});
|
||||
|
||||
it("includes a YAML example", () => {
|
||||
const prompt = yamlPrompt(testCatalog);
|
||||
expect(prompt).toContain("root: main");
|
||||
expect(prompt).toContain("elements:");
|
||||
expect(prompt).toContain("type: Card");
|
||||
});
|
||||
|
||||
it("respects mode: inline", () => {
|
||||
const prompt = yamlPrompt(testCatalog, { mode: "inline" });
|
||||
expect(prompt).toContain("respond conversationally");
|
||||
});
|
||||
|
||||
it("respects mode: standalone", () => {
|
||||
const prompt = yamlPrompt(testCatalog, { mode: "standalone" });
|
||||
expect(prompt).toContain("Output ONLY");
|
||||
});
|
||||
|
||||
it("appends custom rules", () => {
|
||||
const prompt = yamlPrompt(testCatalog, {
|
||||
customRules: ["Always use dark theme colors"],
|
||||
});
|
||||
expect(prompt).toContain("Always use dark theme colors");
|
||||
});
|
||||
|
||||
it("uses custom system message", () => {
|
||||
const prompt = yamlPrompt(testCatalog, {
|
||||
system: "You are a dashboard builder.",
|
||||
});
|
||||
expect(prompt).toContain("You are a dashboard builder.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,582 @@
|
||||
import { stringify } from "yaml";
|
||||
import type { Catalog, EditMode, SchemaDefinition } from "@json-render/core";
|
||||
import { buildEditInstructions } from "@json-render/core";
|
||||
|
||||
interface ZodLike {
|
||||
_def?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface YamlPromptOptions {
|
||||
/** Custom system message intro. */
|
||||
system?: string;
|
||||
/**
|
||||
* - `"standalone"` (default): LLM outputs only the YAML spec (no prose).
|
||||
* - `"inline"`: LLM responds conversationally, then wraps YAML in a fence.
|
||||
*/
|
||||
mode?: "standalone" | "inline";
|
||||
/** Additional rules appended to the RULES section. */
|
||||
customRules?: string[];
|
||||
/** Edit modes to document. Default: `["merge"]` (yaml-edit). */
|
||||
editModes?: EditMode[];
|
||||
}
|
||||
|
||||
interface CatalogComponentDef {
|
||||
props?: ZodLike;
|
||||
description?: string;
|
||||
slots?: string[];
|
||||
events?: string[];
|
||||
example?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Zod introspection (local, minimal) ──────────────────────────────────────
|
||||
|
||||
function getZodTypeName(schema: ZodLike): string {
|
||||
if (!schema?._def) return "";
|
||||
const def = schema._def;
|
||||
return (
|
||||
(def.typeName as string | undefined) ??
|
||||
(typeof def.type === "string" ? (def.type as string) : "") ??
|
||||
""
|
||||
);
|
||||
}
|
||||
|
||||
function formatZodType(schema: ZodLike): string {
|
||||
if (!schema?._def) return "unknown";
|
||||
const def = schema._def;
|
||||
const typeName = getZodTypeName(schema);
|
||||
|
||||
switch (typeName) {
|
||||
case "ZodString":
|
||||
case "string":
|
||||
return "string";
|
||||
case "ZodNumber":
|
||||
case "number":
|
||||
return "number";
|
||||
case "ZodBoolean":
|
||||
case "boolean":
|
||||
return "boolean";
|
||||
case "ZodLiteral":
|
||||
case "literal":
|
||||
return JSON.stringify(def.value);
|
||||
case "ZodEnum":
|
||||
case "enum": {
|
||||
let values: string[];
|
||||
if (Array.isArray(def.values)) {
|
||||
values = def.values as string[];
|
||||
} else if (def.entries && typeof def.entries === "object") {
|
||||
values = Object.values(def.entries as Record<string, string>);
|
||||
} else {
|
||||
return "enum";
|
||||
}
|
||||
return values.map((v) => `"${v}"`).join(" | ");
|
||||
}
|
||||
case "ZodArray":
|
||||
case "array": {
|
||||
const inner = (
|
||||
typeof def.element === "object"
|
||||
? def.element
|
||||
: typeof def.type === "object"
|
||||
? def.type
|
||||
: undefined
|
||||
) as ZodLike | undefined;
|
||||
return inner ? `Array<${formatZodType(inner)}>` : "Array<unknown>";
|
||||
}
|
||||
case "ZodObject":
|
||||
case "object": {
|
||||
const shape =
|
||||
typeof def.shape === "function"
|
||||
? (def.shape as () => Record<string, ZodLike>)()
|
||||
: (def.shape as Record<string, ZodLike>);
|
||||
if (!shape) return "object";
|
||||
const props = Object.entries(shape)
|
||||
.map(([key, value]) => {
|
||||
const innerTypeName = getZodTypeName(value);
|
||||
const isOptional =
|
||||
innerTypeName === "ZodOptional" ||
|
||||
innerTypeName === "ZodNullable" ||
|
||||
innerTypeName === "optional" ||
|
||||
innerTypeName === "nullable";
|
||||
return `${key}${isOptional ? "?" : ""}: ${formatZodType(value)}`;
|
||||
})
|
||||
.join(", ");
|
||||
return `{ ${props} }`;
|
||||
}
|
||||
case "ZodOptional":
|
||||
case "optional":
|
||||
case "ZodNullable":
|
||||
case "nullable": {
|
||||
const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
|
||||
return inner ? formatZodType(inner) : "unknown";
|
||||
}
|
||||
case "ZodUnion":
|
||||
case "union": {
|
||||
const options = def.options as ZodLike[] | undefined;
|
||||
return options
|
||||
? options.map((opt) => formatZodType(opt)).join(" | ")
|
||||
: "unknown";
|
||||
}
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function getExampleProps(def: CatalogComponentDef): Record<string, unknown> {
|
||||
if (def.example && Object.keys(def.example).length > 0) return def.example;
|
||||
if (!def.props?._def) return {};
|
||||
const zodDef = def.props._def;
|
||||
const typeName = getZodTypeName(def.props);
|
||||
if (typeName !== "ZodObject" && typeName !== "object") return {};
|
||||
const shape =
|
||||
typeof zodDef.shape === "function"
|
||||
? (zodDef.shape as () => Record<string, ZodLike>)()
|
||||
: (zodDef.shape as Record<string, ZodLike>);
|
||||
if (!shape) return {};
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(shape)) {
|
||||
const inner = getZodTypeName(value);
|
||||
if (
|
||||
inner === "ZodOptional" ||
|
||||
inner === "optional" ||
|
||||
inner === "ZodNullable" ||
|
||||
inner === "nullable"
|
||||
)
|
||||
continue;
|
||||
result[key] = exampleValue(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function exampleValue(schema: ZodLike): unknown {
|
||||
if (!schema?._def) return "...";
|
||||
const def = schema._def;
|
||||
const t = getZodTypeName(schema);
|
||||
switch (t) {
|
||||
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 as unknown[]).length > 0)
|
||||
return (def.values as unknown[])[0];
|
||||
if (def.entries && typeof def.entries === "object") {
|
||||
const vals = Object.values(def.entries as Record<string, string>);
|
||||
return vals.length > 0 ? vals[0] : "example";
|
||||
}
|
||||
return "example";
|
||||
}
|
||||
case "ZodOptional":
|
||||
case "optional":
|
||||
case "ZodNullable":
|
||||
case "nullable":
|
||||
case "ZodDefault":
|
||||
case "default": {
|
||||
const inner = (def.innerType as ZodLike) ?? (def.wrapped as ZodLike);
|
||||
return inner ? exampleValue(inner) : null;
|
||||
}
|
||||
case "ZodArray":
|
||||
case "array":
|
||||
return [];
|
||||
case "ZodObject":
|
||||
case "object":
|
||||
return getExampleProps({ props: schema } as CatalogComponentDef);
|
||||
case "ZodUnion":
|
||||
case "union": {
|
||||
const options = def.options as ZodLike[] | undefined;
|
||||
return options && options.length > 0 ? exampleValue(options[0]!) : "...";
|
||||
}
|
||||
default:
|
||||
return "...";
|
||||
}
|
||||
}
|
||||
|
||||
// ── YAML helper ──
|
||||
|
||||
/** Render a value as an indented YAML string (2-space indent). */
|
||||
function toYaml(value: unknown): string {
|
||||
return stringify(value, { indent: 2 }).trimEnd();
|
||||
}
|
||||
|
||||
// ── Prompt generation ──
|
||||
|
||||
/**
|
||||
* Generate a YAML-format system prompt from any json-render catalog.
|
||||
*
|
||||
* Works with catalogs from any renderer (`@json-render/react`,
|
||||
* `@json-render/vue`, etc.) — it only reads the catalog metadata.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { yamlPrompt } from "@json-render/yaml";
|
||||
* const systemPrompt = yamlPrompt(catalog, { mode: "inline" });
|
||||
* ```
|
||||
*/
|
||||
export function yamlPrompt(
|
||||
catalog: Catalog<SchemaDefinition, unknown>,
|
||||
options?: YamlPromptOptions,
|
||||
): string {
|
||||
const {
|
||||
system = "You are a UI generator that outputs YAML.",
|
||||
mode = "standalone",
|
||||
customRules = [],
|
||||
editModes = ["merge"],
|
||||
} = options ?? {};
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(system);
|
||||
lines.push("");
|
||||
|
||||
// ── Output format ──
|
||||
|
||||
if (mode === "inline") {
|
||||
lines.push("OUTPUT FORMAT (text + YAML):");
|
||||
lines.push(
|
||||
"You respond conversationally. When generating UI, first write a brief explanation (1-3 sentences), then output the YAML spec wrapped in a ```yaml-spec code fence.",
|
||||
);
|
||||
lines.push(
|
||||
"If the user's message does not require a UI (e.g. a greeting or clarifying question), respond with text only — no YAML.",
|
||||
);
|
||||
} else {
|
||||
lines.push("OUTPUT FORMAT (YAML):");
|
||||
lines.push(
|
||||
"Output a YAML document that describes a UI tree. Wrap it in a ```yaml-spec code fence.",
|
||||
);
|
||||
}
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"The YAML document has three top-level keys: root, elements, and state (optional).",
|
||||
);
|
||||
lines.push(
|
||||
"Stream progressively — output elements one at a time so the UI fills in as you write.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Example ──
|
||||
|
||||
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) : {};
|
||||
|
||||
const exampleSpec = {
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: comp1,
|
||||
props: comp1Props,
|
||||
children: ["child-1", "list"],
|
||||
},
|
||||
"child-1": {
|
||||
type: comp2,
|
||||
props: comp2Props,
|
||||
children: [],
|
||||
},
|
||||
list: {
|
||||
type: comp1,
|
||||
props: comp1Props,
|
||||
repeat: { statePath: "/items", key: "id" },
|
||||
children: ["item"],
|
||||
},
|
||||
item: {
|
||||
type: comp2,
|
||||
props: { ...comp2Props },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
state: {
|
||||
items: [
|
||||
{ id: "1", title: "First Item" },
|
||||
{ id: "2", title: "Second Item" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
lines.push("Example:");
|
||||
lines.push("");
|
||||
lines.push("```yaml-spec");
|
||||
lines.push(toYaml(exampleSpec));
|
||||
lines.push("```");
|
||||
lines.push("");
|
||||
|
||||
// ── Edit modes (dynamic based on config) ──
|
||||
|
||||
lines.push(buildEditInstructions({ modes: editModes }, "yaml"));
|
||||
|
||||
// ── Initial state ──
|
||||
|
||||
lines.push("INITIAL STATE:");
|
||||
lines.push(
|
||||
"The spec includes a top-level `state` key to seed the state model. Components using $state, $bindState, $bindItem, $item, or $index read from this state.",
|
||||
);
|
||||
lines.push(
|
||||
"CRITICAL: You MUST include state whenever your UI displays data via these expressions or uses repeat to iterate over arrays.",
|
||||
);
|
||||
lines.push(
|
||||
"Include realistic sample data. For lists: 3-5 items with relevant fields. Never leave arrays empty.",
|
||||
);
|
||||
lines.push(
|
||||
'When content comes from the state model, use { "$state": "/some/path" } dynamic props instead of hardcoding values.',
|
||||
);
|
||||
lines.push(
|
||||
'State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use dot notation.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Dynamic lists ──
|
||||
|
||||
lines.push("DYNAMIC LISTS (repeat field):");
|
||||
lines.push(
|
||||
"Any element can have a top-level `repeat` field to render its children once per item in a state array.",
|
||||
);
|
||||
lines.push("Example:");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
toYaml({
|
||||
list: {
|
||||
type: comp1,
|
||||
props: comp1Props,
|
||||
repeat: { statePath: "/todos", key: "id" },
|
||||
children: ["todo-item"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'Inside repeated children, use { "$item": "field" } for item data and { "$index": true } for the array index.',
|
||||
);
|
||||
lines.push(
|
||||
"ALWAYS use repeat for lists backed by state arrays. NEVER hardcode individual elements per item.",
|
||||
);
|
||||
lines.push(
|
||||
"IMPORTANT: `repeat` is a top-level field on the element (sibling of type/props/children), NOT inside props.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Array state actions ──
|
||||
|
||||
lines.push("ARRAY STATE ACTIONS:");
|
||||
lines.push(
|
||||
'Use "pushState" to append items to arrays. Use "removeState" to remove items by index.',
|
||||
);
|
||||
lines.push('Use "$id" inside pushState values to auto-generate a unique ID.');
|
||||
lines.push("");
|
||||
|
||||
// ── Components ──
|
||||
|
||||
if (allComponents) {
|
||||
lines.push(`AVAILABLE COMPONENTS (${catalog.componentNames.length}):`);
|
||||
lines.push("");
|
||||
|
||||
for (const [name, def] of Object.entries(allComponents)) {
|
||||
const propsStr = def.props ? formatZodType(def.props) : "{}";
|
||||
const hasChildren = def.slots && def.slots.length > 0;
|
||||
const childrenStr = hasChildren ? " [accepts children]" : "";
|
||||
const eventsStr =
|
||||
def.events && def.events.length > 0
|
||||
? ` [events: ${def.events.join(", ")}]`
|
||||
: "";
|
||||
const descStr = def.description ? ` - ${def.description}` : "";
|
||||
lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Actions ──
|
||||
|
||||
const actions = (catalog.data as Record<string, unknown>).actions as
|
||||
| Record<string, { params?: ZodLike; description?: string }>
|
||||
| undefined;
|
||||
const builtInActions = catalog.schema.builtInActions ?? [];
|
||||
const hasCustomActions = actions && catalog.actionNames.length > 0;
|
||||
const hasBuiltInActions = builtInActions.length > 0;
|
||||
|
||||
if (hasCustomActions || hasBuiltInActions) {
|
||||
lines.push("AVAILABLE ACTIONS:");
|
||||
lines.push("");
|
||||
for (const action of builtInActions) {
|
||||
lines.push(`- ${action.name}: ${action.description} [built-in]`);
|
||||
}
|
||||
if (hasCustomActions) {
|
||||
for (const [name, def] of Object.entries(actions)) {
|
||||
lines.push(`- ${name}${def.description ? `: ${def.description}` : ""}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Events ──
|
||||
|
||||
lines.push("EVENTS (the `on` field):");
|
||||
lines.push(
|
||||
"Elements can have an optional `on` field to bind events to actions. It is a top-level field (sibling of type/props/children), NOT inside props.",
|
||||
);
|
||||
lines.push("Example:");
|
||||
lines.push("");
|
||||
lines.push(
|
||||
toYaml({
|
||||
"save-btn": {
|
||||
type: comp1,
|
||||
props: comp1Props,
|
||||
on: {
|
||||
press: {
|
||||
action: "setState",
|
||||
params: { statePath: "/saved", value: true },
|
||||
},
|
||||
},
|
||||
children: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
lines.push("");
|
||||
lines.push(
|
||||
'Action params can use dynamic references: { "$state": "/statePath" }.',
|
||||
);
|
||||
lines.push(
|
||||
"IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field.",
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── Visibility ──
|
||||
|
||||
lines.push("VISIBILITY CONDITIONS:");
|
||||
lines.push(
|
||||
"Elements can have an optional `visible` field to conditionally show/hide based on state. It is a top-level field (sibling of type/props/children).",
|
||||
);
|
||||
lines.push("Conditions:");
|
||||
lines.push('- { "$state": "/path" } — visible when truthy');
|
||||
lines.push('- { "$state": "/path", "not": true } — visible when falsy');
|
||||
lines.push('- { "$state": "/path", "eq": "value" } — visible when equal');
|
||||
lines.push(
|
||||
'- { "$state": "/path", "neq": "value" } — visible when not equal',
|
||||
);
|
||||
lines.push(
|
||||
'- { "$state": "/path", "gt": N } / gte / lt / lte — numeric comparisons',
|
||||
);
|
||||
lines.push("- Use ONE operator per condition. Do not combine multiple.");
|
||||
lines.push('- Add "not": true to any condition to invert it.');
|
||||
lines.push("- [cond, cond] — implicit AND (all must be true)");
|
||||
lines.push('- { "$and": [...] } — explicit AND');
|
||||
lines.push('- { "$or": [...] } — at least one must be true');
|
||||
lines.push("- true / false — always visible/hidden");
|
||||
lines.push("");
|
||||
|
||||
// ── Dynamic props ──
|
||||
|
||||
lines.push("DYNAMIC PROPS:");
|
||||
lines.push("Any prop value can be a dynamic expression:");
|
||||
lines.push(
|
||||
'1. Read-only: { "$state": "/path" } — resolves to the value at that state path.',
|
||||
);
|
||||
lines.push(
|
||||
'2. Two-way binding: { "$bindState": "/path" } — read + write. Use on form inputs.',
|
||||
);
|
||||
lines.push(
|
||||
' Inside repeat scopes: { "$bindItem": "field" } for item-level binding.',
|
||||
);
|
||||
lines.push(
|
||||
'3. Conditional: { "$cond": <condition>, "$then": <val>, "$else": <val> }',
|
||||
);
|
||||
lines.push(
|
||||
'4. Template: { "$template": "Hello, ${/name}!" } — interpolates state references.',
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
// ── $computed (only if catalog has functions) ──
|
||||
|
||||
const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
|
||||
if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
|
||||
lines.push(
|
||||
'5. Computed: { "$computed": "<fn>", "args": { "key": <expr> } }',
|
||||
);
|
||||
lines.push(" Available functions:");
|
||||
for (const name of Object.keys(
|
||||
catalogFunctions as Record<string, unknown>,
|
||||
)) {
|
||||
lines.push(` - ${name}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Validation (only if components have checks) ──
|
||||
|
||||
const hasChecks = allComponents
|
||||
? Object.values(allComponents).some((def) => {
|
||||
if (!def.props) return false;
|
||||
return formatZodType(def.props).includes("checks");
|
||||
})
|
||||
: false;
|
||||
|
||||
if (hasChecks) {
|
||||
lines.push("VALIDATION:");
|
||||
lines.push(
|
||||
"Form components with a `checks` prop support client-side validation.",
|
||||
);
|
||||
lines.push(
|
||||
"Built-in types: required, email, minLength, maxLength, pattern, min, max, numeric, url, matches, equalTo, lessThan, greaterThan, requiredIf.",
|
||||
);
|
||||
lines.push(
|
||||
"IMPORTANT: Components with checks must also use $bindState or $bindItem for two-way binding.",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── State watchers ──
|
||||
|
||||
if (hasCustomActions || hasBuiltInActions) {
|
||||
lines.push("STATE WATCHERS:");
|
||||
lines.push(
|
||||
"Elements can have an optional `watch` field to trigger actions when state changes. Top-level field, NOT inside props.",
|
||||
);
|
||||
lines.push(
|
||||
"Maps state paths to action bindings. Fires when the watched value changes (not on initial render).",
|
||||
);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
// ── Rules ──
|
||||
|
||||
lines.push("RULES:");
|
||||
const baseRules =
|
||||
mode === "inline"
|
||||
? [
|
||||
"When generating UI, wrap the YAML in a ```yaml-spec code fence",
|
||||
"Write a brief conversational response before the YAML",
|
||||
"When editing existing UI, use a ```yaml-edit fence with only the changed parts",
|
||||
"The document must have: root (string), elements (map of elements), and optionally state",
|
||||
"Each element must have: type, props, children (list of child keys)",
|
||||
"ONLY use components listed above",
|
||||
"Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
|
||||
"Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
|
||||
]
|
||||
: [
|
||||
"Output ONLY the YAML spec inside a ```yaml-spec fence — no prose, no extra markdown",
|
||||
"When editing existing UI, use a ```yaml-edit fence with only the changed parts",
|
||||
"The document must have: root (string), elements (map of elements), and optionally state",
|
||||
"Each element must have: type, props, children (list of child keys)",
|
||||
"ONLY use components listed above",
|
||||
"Use unique, descriptive keys for elements (e.g. 'header', 'metric-1', 'chart-revenue')",
|
||||
"Include state whenever using $state, $bindState, $bindItem, $item, $index, or repeat",
|
||||
];
|
||||
|
||||
const schemaRules = catalog.schema.defaultRules ?? [];
|
||||
const allRules = [...baseRules, ...schemaRules, ...customRules];
|
||||
allRules.forEach((rule, i) => {
|
||||
lines.push(`${i + 1}. ${rule}`);
|
||||
});
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SPEC_DATA_PART_TYPE, type StreamChunk } from "@json-render/core";
|
||||
import { createYamlTransform } from "./transform";
|
||||
|
||||
/** Helper: feed text chunks through the transform and collect output. */
|
||||
async function runTransform(
|
||||
lines: string[],
|
||||
options?: Parameters<typeof createYamlTransform>[0],
|
||||
): Promise<StreamChunk[]> {
|
||||
const transform = createYamlTransform(options);
|
||||
const output: StreamChunk[] = [];
|
||||
|
||||
// Build input chunks
|
||||
const inputChunks: StreamChunk[] = [
|
||||
{ type: "text-start", id: "1" },
|
||||
...lines.map((l) => ({ type: "text-delta" as const, id: "1", delta: l })),
|
||||
{ type: "text-end", id: "1" },
|
||||
];
|
||||
|
||||
// Create a readable from the input chunks and pipe through
|
||||
const input = new ReadableStream<StreamChunk>({
|
||||
start(controller) {
|
||||
for (const chunk of inputChunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const outputStream = input.pipeThrough(transform);
|
||||
const reader = outputStream.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
output.push(value);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function extractPatches(output: StreamChunk[]) {
|
||||
return output
|
||||
.filter((c) => c.type === SPEC_DATA_PART_TYPE)
|
||||
.map((c) => (c as { data: { type: string; patch: unknown } }).data.patch);
|
||||
}
|
||||
|
||||
function extractText(output: StreamChunk[]) {
|
||||
return output
|
||||
.filter((c) => c.type === "text-delta")
|
||||
.map((c) => (c as { delta: string }).delta)
|
||||
.join("");
|
||||
}
|
||||
|
||||
describe("createYamlTransform", () => {
|
||||
it("passes through plain text outside fences", async () => {
|
||||
const output = await runTransform(["Hello world\n", "How are you?\n"]);
|
||||
const text = extractText(output);
|
||||
expect(text).toContain("Hello world");
|
||||
expect(text).toContain("How are you?");
|
||||
});
|
||||
|
||||
it("parses yaml-spec fence into patches", async () => {
|
||||
const output = await runTransform([
|
||||
"Here is your UI:\n",
|
||||
"```yaml-spec\n",
|
||||
"root: main\n",
|
||||
"elements:\n",
|
||||
" main:\n",
|
||||
" type: Card\n",
|
||||
" props:\n",
|
||||
" title: Dashboard\n",
|
||||
" children: []\n",
|
||||
"```\n",
|
||||
]);
|
||||
|
||||
const patches = extractPatches(output);
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
|
||||
// Should have patched the root
|
||||
const rootPatch = patches.find(
|
||||
(p: any) => p.path === "/root" && p.value === "main",
|
||||
);
|
||||
expect(rootPatch).toBeDefined();
|
||||
|
||||
// Text before the fence should pass through
|
||||
const text = extractText(output);
|
||||
expect(text).toContain("Here is your UI:");
|
||||
});
|
||||
|
||||
it("parses yaml-edit fence with merge semantics", async () => {
|
||||
const previousSpec = {
|
||||
root: "main",
|
||||
elements: {
|
||||
main: {
|
||||
type: "Card",
|
||||
props: { title: "Old Title" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Only send a yaml-edit block — previousSpec is the base
|
||||
const output = await runTransform(
|
||||
[
|
||||
"```yaml-edit\n",
|
||||
"elements:\n",
|
||||
" main:\n",
|
||||
" props:\n",
|
||||
" title: New Title\n",
|
||||
"```\n",
|
||||
],
|
||||
{ previousSpec: previousSpec as any },
|
||||
);
|
||||
|
||||
const patches = extractPatches(output);
|
||||
|
||||
// Should have at least one patch
|
||||
expect(patches.length).toBeGreaterThan(0);
|
||||
|
||||
// Should include a patch that updates the title — could be at
|
||||
// the leaf level or replacing the whole props object
|
||||
const hasTitleUpdate = patches.some(
|
||||
(p: any) =>
|
||||
(p.path === "/elements/main/props/title" && p.value === "New Title") ||
|
||||
(p.path === "/elements/main/props" &&
|
||||
(p.value as any)?.title === "New Title"),
|
||||
);
|
||||
expect(hasTitleUpdate).toBe(true);
|
||||
});
|
||||
|
||||
it("swallows fence delimiters (not emitted as text)", async () => {
|
||||
const output = await runTransform([
|
||||
"```yaml-spec\n",
|
||||
"root: main\n",
|
||||
"```\n",
|
||||
]);
|
||||
|
||||
const text = extractText(output);
|
||||
expect(text).not.toContain("```yaml-spec");
|
||||
expect(text).not.toContain("```");
|
||||
});
|
||||
|
||||
it("handles non-text chunks by passing them through", async () => {
|
||||
const transform = createYamlTransform();
|
||||
const input = new ReadableStream<StreamChunk>({
|
||||
start(controller) {
|
||||
controller.enqueue({ type: "tool-call", id: "t1", name: "getWeather" });
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const reader = input.pipeThrough(transform).getReader();
|
||||
const { value } = await reader.read();
|
||||
expect(value).toEqual({
|
||||
type: "tool-call",
|
||||
id: "t1",
|
||||
name: "getWeather",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,448 @@
|
||||
import { parse, stringify } from "yaml";
|
||||
import { applyPatch as applyUnifiedDiff } from "diff";
|
||||
import {
|
||||
SPEC_DATA_PART_TYPE,
|
||||
deepMergeSpec,
|
||||
diffToPatches,
|
||||
type JsonPatch,
|
||||
type Spec,
|
||||
type StreamChunk,
|
||||
} from "@json-render/core";
|
||||
import { createYamlStreamCompiler } from "./parser";
|
||||
|
||||
export const YAML_SPEC_FENCE = "```yaml-spec";
|
||||
export const YAML_EDIT_FENCE = "```yaml-edit";
|
||||
export const YAML_PATCH_FENCE = "```yaml-patch";
|
||||
export const DIFF_FENCE = "```diff";
|
||||
export const FENCE_CLOSE = "```";
|
||||
|
||||
export interface YamlTransformOptions {
|
||||
/** Seed with a previous spec for multi-turn edit support. */
|
||||
previousSpec?: Spec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `TransformStream` that intercepts AI SDK UI message stream chunks
|
||||
* and converts YAML spec/edit blocks into json-render patch data parts.
|
||||
*
|
||||
* Two fence types are recognised:
|
||||
*
|
||||
* 1. **`\`\`\`yaml-spec`** — Full YAML spec. Parsed progressively, emitting
|
||||
* patches as each new property is detected.
|
||||
* 2. **`\`\`\`yaml-edit`** — Partial YAML. Deep-merged with the current spec,
|
||||
* then diffed to produce patches. Only changed keys need to be included.
|
||||
*
|
||||
* Non-fenced text passes through unchanged as `text-delta` chunks, matching
|
||||
* the behaviour of `createJsonRenderTransform` from `@json-render/core`.
|
||||
*/
|
||||
export function createYamlTransform(
|
||||
options?: YamlTransformOptions,
|
||||
): TransformStream<StreamChunk, StreamChunk> {
|
||||
let currentTextId = "";
|
||||
let inTextBlock = false;
|
||||
let textIdCounter = 0;
|
||||
|
||||
let lineBuffer = "";
|
||||
let buffering = false;
|
||||
|
||||
// Fence state
|
||||
let fenceMode: "yaml-spec" | "yaml-edit" | "yaml-patch" | "diff" | null =
|
||||
null;
|
||||
let yamlAccumulated = "";
|
||||
let diffAccumulated = "";
|
||||
|
||||
// Streaming compiler for yaml-spec progressive rendering
|
||||
let compiler = createYamlStreamCompiler<Record<string, unknown>>();
|
||||
|
||||
// The "current spec" — built up during yaml-spec, used as base for yaml-edit
|
||||
let currentSpec: Record<string, unknown> = options?.previousSpec
|
||||
? structuredClone(
|
||||
options.previousSpec as unknown as Record<string, unknown>,
|
||||
)
|
||||
: {};
|
||||
|
||||
// ── Text block helpers (same pattern as createJsonRenderTransform) ──
|
||||
|
||||
function closeTextBlock(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
if (inTextBlock) {
|
||||
controller.enqueue({ type: "text-end", id: currentTextId });
|
||||
inTextBlock = false;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureTextBlock(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
if (!inTextBlock) {
|
||||
textIdCounter++;
|
||||
currentTextId = String(textIdCounter);
|
||||
controller.enqueue({ type: "text-start", id: currentTextId });
|
||||
inTextBlock = true;
|
||||
}
|
||||
}
|
||||
|
||||
function emitTextDelta(
|
||||
delta: string,
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
ensureTextBlock(controller);
|
||||
controller.enqueue({ type: "text-delta", id: currentTextId, delta });
|
||||
}
|
||||
|
||||
function emitPatch(
|
||||
patch: JsonPatch,
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
closeTextBlock(controller);
|
||||
controller.enqueue({
|
||||
type: SPEC_DATA_PART_TYPE,
|
||||
data: { type: "patch", patch },
|
||||
});
|
||||
}
|
||||
|
||||
function emitPatches(
|
||||
patches: JsonPatch[],
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
for (const patch of patches) {
|
||||
emitPatch(patch, controller);
|
||||
}
|
||||
}
|
||||
|
||||
// ── YAML fence processing ──
|
||||
|
||||
/**
|
||||
* Feed a line of YAML to the streaming compiler (yaml-spec mode).
|
||||
* Emits patches for any newly detected properties.
|
||||
*/
|
||||
function feedYamlSpec(
|
||||
line: string,
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
yamlAccumulated += line + "\n";
|
||||
const { newPatches } = compiler.push(line + "\n");
|
||||
if (newPatches.length > 0) {
|
||||
emitPatches(newPatches, controller);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed a line of YAML for edit mode. We accumulate all lines and process
|
||||
* on fence close since partial edits may not parse until complete.
|
||||
*/
|
||||
function feedYamlEdit(line: string) {
|
||||
yamlAccumulated += line + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise a yaml-edit block: parse the accumulated YAML, deep-merge
|
||||
* with the current spec, diff, and emit patches.
|
||||
*/
|
||||
function finaliseYamlEdit(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
try {
|
||||
const editObj = parse(yamlAccumulated);
|
||||
if (editObj && typeof editObj === "object" && !Array.isArray(editObj)) {
|
||||
const merged = deepMergeSpec(
|
||||
currentSpec,
|
||||
editObj as Record<string, unknown>,
|
||||
);
|
||||
const patches = diffToPatches(currentSpec, merged);
|
||||
if (patches.length > 0) {
|
||||
currentSpec = merged;
|
||||
emitPatches(patches, controller);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid YAML edit block — silently drop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise a yaml-spec block: flush the compiler for any remaining
|
||||
* partial data and update currentSpec.
|
||||
*/
|
||||
function finaliseYamlSpec(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
const { newPatches } = compiler.flush();
|
||||
if (newPatches.length > 0) {
|
||||
emitPatches(newPatches, controller);
|
||||
}
|
||||
currentSpec = structuredClone(
|
||||
compiler.getResult() as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise a yaml-patch block: parse each accumulated line as an
|
||||
* RFC 6902 JSON Patch operation and emit directly.
|
||||
*/
|
||||
function finaliseYamlPatch(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
for (const line of yamlAccumulated.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const patch = JSON.parse(trimmed) as JsonPatch;
|
||||
if (patch.op) {
|
||||
emitPatch(patch, controller);
|
||||
// Update currentSpec for subsequent edits
|
||||
if (patch.op === "add" || patch.op === "replace") {
|
||||
const parts = patch.path.split("/").filter(Boolean);
|
||||
let target: Record<string, unknown> = currentSpec;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (typeof target[key] !== "object" || target[key] === null) {
|
||||
target[key] = {};
|
||||
}
|
||||
target = target[key] as Record<string, unknown>;
|
||||
}
|
||||
const lastKey = parts[parts.length - 1];
|
||||
if (lastKey) target[lastKey] = patch.value;
|
||||
} else if (patch.op === "remove") {
|
||||
const parts = patch.path.split("/").filter(Boolean);
|
||||
let target: Record<string, unknown> = currentSpec;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const key = parts[i]!;
|
||||
if (typeof target[key] !== "object" || target[key] === null)
|
||||
break;
|
||||
target = target[key] as Record<string, unknown>;
|
||||
}
|
||||
const lastKey = parts[parts.length - 1];
|
||||
if (lastKey) delete target[lastKey];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip invalid JSON lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalise a diff block: apply the unified diff to the YAML-serialized
|
||||
* current spec, reparse, diff against current, and emit patches.
|
||||
*/
|
||||
function finaliseDiff(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
try {
|
||||
const currentYaml = stringify(currentSpec, { indent: 2 });
|
||||
const patched = applyUnifiedDiff(currentYaml, diffAccumulated);
|
||||
if (patched === false) return;
|
||||
const newSpec = parse(patched);
|
||||
if (newSpec && typeof newSpec === "object" && !Array.isArray(newSpec)) {
|
||||
const patches = diffToPatches(
|
||||
currentSpec,
|
||||
newSpec as Record<string, unknown>,
|
||||
);
|
||||
if (patches.length > 0) {
|
||||
currentSpec = newSpec as Record<string, unknown>;
|
||||
emitPatches(patches, controller);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Diff apply or reparse failed
|
||||
}
|
||||
}
|
||||
|
||||
// ── Line processing ──
|
||||
|
||||
function processCompleteLine(
|
||||
line: string,
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Fence open detection
|
||||
if (fenceMode === null) {
|
||||
if (trimmed.startsWith(YAML_SPEC_FENCE)) {
|
||||
fenceMode = "yaml-spec";
|
||||
yamlAccumulated = "";
|
||||
compiler.reset(currentSpec);
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith(YAML_EDIT_FENCE)) {
|
||||
fenceMode = "yaml-edit";
|
||||
yamlAccumulated = "";
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith(YAML_PATCH_FENCE)) {
|
||||
fenceMode = "yaml-patch";
|
||||
yamlAccumulated = "";
|
||||
return;
|
||||
}
|
||||
if (trimmed.startsWith(DIFF_FENCE)) {
|
||||
fenceMode = "diff";
|
||||
diffAccumulated = "";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fence close detection
|
||||
if (fenceMode !== null && trimmed === FENCE_CLOSE) {
|
||||
if (fenceMode === "yaml-spec") {
|
||||
finaliseYamlSpec(controller);
|
||||
} else if (fenceMode === "yaml-edit") {
|
||||
finaliseYamlEdit(controller);
|
||||
} else if (fenceMode === "yaml-patch") {
|
||||
finaliseYamlPatch(controller);
|
||||
} else if (fenceMode === "diff") {
|
||||
finaliseDiff(controller);
|
||||
}
|
||||
fenceMode = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Inside a fence
|
||||
if (fenceMode === "yaml-spec") {
|
||||
feedYamlSpec(line, controller);
|
||||
return;
|
||||
}
|
||||
if (fenceMode === "yaml-edit") {
|
||||
feedYamlEdit(line);
|
||||
return;
|
||||
}
|
||||
if (fenceMode === "yaml-patch") {
|
||||
yamlAccumulated += line + "\n";
|
||||
return;
|
||||
}
|
||||
if (fenceMode === "diff") {
|
||||
diffAccumulated += line + "\n";
|
||||
return;
|
||||
}
|
||||
|
||||
// Outside fence — pass through as text
|
||||
if (!trimmed) {
|
||||
emitTextDelta("\n", controller);
|
||||
return;
|
||||
}
|
||||
emitTextDelta(line + "\n", controller);
|
||||
}
|
||||
|
||||
function flushBuffer(
|
||||
controller: TransformStreamDefaultController<StreamChunk>,
|
||||
) {
|
||||
if (!lineBuffer) return;
|
||||
|
||||
if (fenceMode !== null) {
|
||||
processCompleteLine(lineBuffer, controller);
|
||||
} else {
|
||||
emitTextDelta(lineBuffer, controller);
|
||||
}
|
||||
lineBuffer = "";
|
||||
buffering = false;
|
||||
}
|
||||
|
||||
// ── TransformStream ──
|
||||
|
||||
return new TransformStream<StreamChunk, StreamChunk>({
|
||||
transform(chunk, controller) {
|
||||
switch (chunk.type) {
|
||||
case "text-start": {
|
||||
const id = (chunk as { id: string }).id;
|
||||
const idNum = parseInt(id, 10);
|
||||
if (!isNaN(idNum) && idNum >= textIdCounter) {
|
||||
textIdCounter = idNum;
|
||||
}
|
||||
currentTextId = id;
|
||||
inTextBlock = true;
|
||||
controller.enqueue(chunk);
|
||||
break;
|
||||
}
|
||||
|
||||
case "text-delta": {
|
||||
const delta = chunk as { id: string; delta: string };
|
||||
const text = delta.delta;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text.charAt(i);
|
||||
|
||||
if (ch === "\n") {
|
||||
if (buffering) {
|
||||
processCompleteLine(lineBuffer, controller);
|
||||
lineBuffer = "";
|
||||
buffering = false;
|
||||
} else if (fenceMode === null) {
|
||||
emitTextDelta("\n", controller);
|
||||
}
|
||||
} else if (lineBuffer.length === 0 && !buffering) {
|
||||
// Inside a fence, buffer everything. Outside, only buffer
|
||||
// potential fence-open lines (start with backtick).
|
||||
if (fenceMode !== null || ch === "`") {
|
||||
buffering = true;
|
||||
lineBuffer += ch;
|
||||
} else {
|
||||
emitTextDelta(ch, controller);
|
||||
}
|
||||
} else if (buffering) {
|
||||
lineBuffer += ch;
|
||||
} else {
|
||||
emitTextDelta(ch, controller);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "text-end": {
|
||||
flushBuffer(controller);
|
||||
if (inTextBlock) {
|
||||
controller.enqueue({ type: "text-end", id: currentTextId });
|
||||
inTextBlock = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
controller.enqueue(chunk);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
flushBuffer(controller);
|
||||
if (fenceMode === "yaml-spec") {
|
||||
finaliseYamlSpec(controller);
|
||||
} else if (fenceMode === "yaml-edit") {
|
||||
finaliseYamlEdit(controller);
|
||||
} else if (fenceMode === "yaml-patch") {
|
||||
finaliseYamlPatch(controller);
|
||||
} else if (fenceMode === "diff") {
|
||||
finaliseDiff(controller);
|
||||
}
|
||||
closeTextBlock(controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper that pipes an AI SDK UI message stream through the
|
||||
* YAML transform, converting YAML spec/edit blocks into json-render patches.
|
||||
*
|
||||
* Drop-in replacement for `pipeJsonRender` from `@json-render/core`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { pipeYamlRender } from "@json-render/yaml";
|
||||
*
|
||||
* const stream = createUIMessageStream({
|
||||
* execute: async ({ writer }) => {
|
||||
* writer.merge(pipeYamlRender(result.toUIMessageStream()));
|
||||
* },
|
||||
* });
|
||||
* return createUIMessageStreamResponse({ stream });
|
||||
* ```
|
||||
*/
|
||||
export function pipeYamlRender<T = StreamChunk>(
|
||||
stream: ReadableStream<T>,
|
||||
options?: YamlTransformOptions,
|
||||
): ReadableStream<T> {
|
||||
return stream.pipeThrough(
|
||||
createYamlTransform(options) as unknown as TransformStream<T, T>,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@internal/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["cjs", "esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
external: ["@json-render/core", "yaml", "diff"],
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+119
-20
@@ -83,6 +83,9 @@ importers:
|
||||
'@json-render/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/react
|
||||
'@json-render/yaml':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/yaml
|
||||
'@mdx-js/loader':
|
||||
specifier: ^3.1.1
|
||||
version: 3.1.1(webpack@5.96.1)
|
||||
@@ -131,15 +134,15 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
diff:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
embla-carousel-react:
|
||||
specifier: ^8.6.0
|
||||
version: 8.6.0(react@19.2.3)
|
||||
geist:
|
||||
specifier: 1.7.0
|
||||
version: 1.7.0(next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))
|
||||
just-bash:
|
||||
specifier: 2.9.6
|
||||
version: 2.9.6
|
||||
lucide-react:
|
||||
specifier: ^0.562.0
|
||||
version: 0.562.0(react@19.2.3)
|
||||
@@ -182,6 +185,9 @@ importers:
|
||||
vaul:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -1870,6 +1876,34 @@ importers:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
|
||||
packages/yaml:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
diff:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
devDependencies:
|
||||
'@internal/typescript-config':
|
||||
specifier: workspace:*
|
||||
version: link:../typescript-config
|
||||
tsup:
|
||||
specifier: ^8.0.2
|
||||
version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.17
|
||||
version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
|
||||
packages/zustand:
|
||||
dependencies:
|
||||
'@json-render/core':
|
||||
@@ -13767,7 +13801,8 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
'@borewit/text-codec@0.2.1':
|
||||
optional: true
|
||||
|
||||
'@changesets/apply-release-plan@7.0.14':
|
||||
dependencies:
|
||||
@@ -15251,7 +15286,7 @@ snapshots:
|
||||
'@mdx-js/mdx': 3.1.1
|
||||
source-map: 0.7.6
|
||||
optionalDependencies:
|
||||
webpack: 5.96.1(esbuild@0.25.0)
|
||||
webpack: 5.96.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -15293,7 +15328,8 @@ snapshots:
|
||||
|
||||
'@mediapipe/tasks-vision@0.10.17': {}
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
'@mixmark-io/domino@2.2.0':
|
||||
optional: true
|
||||
|
||||
'@modelcontextprotocol/ext-apps@1.2.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(zod@4.3.6)':
|
||||
dependencies:
|
||||
@@ -18287,7 +18323,7 @@ snapshots:
|
||||
'@stripe/ui-extension-tools@0.0.1(@babel/core@7.29.0)(babel-jest@27.5.1(@babel/core@7.29.0))':
|
||||
dependencies:
|
||||
'@types/jest': 28.1.8
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/eslint-plugin': 5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
eslint: 8.57.1
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
@@ -18584,8 +18620,10 @@ snapshots:
|
||||
token-types: 6.1.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@tokenizer/token@0.3.0': {}
|
||||
'@tokenizer/token@0.3.0':
|
||||
optional: true
|
||||
|
||||
'@tootallnate/once@1.1.2': {}
|
||||
|
||||
@@ -18823,7 +18861,7 @@ snapshots:
|
||||
'@types/node': 22.19.6
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@9.39.2(jiti@2.6.1))(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
'@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0(eslint@8.57.1)(typescript@4.9.5))(eslint@8.57.1)(typescript@4.9.5)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
'@typescript-eslint/parser': 5.62.0(eslint@8.57.1)(typescript@4.9.5)
|
||||
@@ -19429,7 +19467,8 @@ snapshots:
|
||||
|
||||
alien-signals@3.1.2: {}
|
||||
|
||||
amdefine@1.0.1: {}
|
||||
amdefine@1.0.1:
|
||||
optional: true
|
||||
|
||||
anser@1.4.10: {}
|
||||
|
||||
@@ -20087,6 +20126,7 @@ snapshots:
|
||||
commander@2.8.1:
|
||||
dependencies:
|
||||
graceful-readlink: 1.0.1
|
||||
optional: true
|
||||
|
||||
commander@4.1.1: {}
|
||||
|
||||
@@ -20112,6 +20152,7 @@ snapshots:
|
||||
dependencies:
|
||||
amdefine: 1.0.1
|
||||
commander: 2.8.1
|
||||
optional: true
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
@@ -21505,6 +21546,7 @@ snapshots:
|
||||
fast-xml-parser@5.3.5:
|
||||
dependencies:
|
||||
strnum: 2.1.2
|
||||
optional: true
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
@@ -21555,6 +21597,7 @@ snapshots:
|
||||
uint8array-extras: 1.5.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
fill-range@7.1.1:
|
||||
dependencies:
|
||||
@@ -21836,7 +21879,8 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
graceful-readlink@1.0.1: {}
|
||||
graceful-readlink@1.0.1:
|
||||
optional: true
|
||||
|
||||
graphemer@1.4.0: {}
|
||||
|
||||
@@ -22146,7 +22190,8 @@ snapshots:
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
ini@6.0.0: {}
|
||||
ini@6.0.0:
|
||||
optional: true
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
@@ -23065,6 +23110,7 @@ snapshots:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
optional: true
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
@@ -24061,7 +24107,8 @@ snapshots:
|
||||
pkg-types: 1.3.1
|
||||
ufo: 1.6.2
|
||||
|
||||
modern-tar@0.7.3: {}
|
||||
modern-tar@0.7.3:
|
||||
optional: true
|
||||
|
||||
mri@1.2.0: {}
|
||||
|
||||
@@ -24455,7 +24502,8 @@ snapshots:
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
papaparse@5.5.3: {}
|
||||
papaparse@5.5.3:
|
||||
optional: true
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
@@ -24749,6 +24797,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- utf-8-validate
|
||||
optional: true
|
||||
|
||||
qrcode-terminal@0.11.0: {}
|
||||
|
||||
@@ -24919,7 +24968,8 @@ snapshots:
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
re2js@1.2.1: {}
|
||||
re2js@1.2.1:
|
||||
optional: true
|
||||
|
||||
react-confetti-explosion@3.0.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
@@ -26025,7 +26075,8 @@ snapshots:
|
||||
|
||||
slugify@1.6.6: {}
|
||||
|
||||
smol-toml@1.6.0: {}
|
||||
smol-toml@1.6.0:
|
||||
optional: true
|
||||
|
||||
solid-js@1.9.11:
|
||||
dependencies:
|
||||
@@ -26082,9 +26133,11 @@ snapshots:
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sprintf-js@1.1.3: {}
|
||||
sprintf-js@1.1.3:
|
||||
optional: true
|
||||
|
||||
sql.js@1.13.0: {}
|
||||
sql.js@1.13.0:
|
||||
optional: true
|
||||
|
||||
stack-utils@2.0.6:
|
||||
dependencies:
|
||||
@@ -26292,11 +26345,13 @@ snapshots:
|
||||
'@types/node': 22.19.6
|
||||
qs: 6.14.1
|
||||
|
||||
strnum@2.1.2: {}
|
||||
strnum@2.1.2:
|
||||
optional: true
|
||||
|
||||
strtok3@10.3.4:
|
||||
dependencies:
|
||||
'@tokenizer/token': 0.3.0
|
||||
optional: true
|
||||
|
||||
structured-headers@0.4.1: {}
|
||||
|
||||
@@ -26485,6 +26540,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.0
|
||||
|
||||
terser-webpack-plugin@5.3.16(webpack@5.96.1):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
terser: 5.46.0
|
||||
webpack: 5.96.1
|
||||
optional: true
|
||||
|
||||
terser@5.46.0:
|
||||
dependencies:
|
||||
'@jridgewell/source-map': 0.3.11
|
||||
@@ -26582,6 +26647,7 @@ snapshots:
|
||||
'@borewit/text-codec': 0.2.1
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
optional: true
|
||||
|
||||
totalist@3.0.1: {}
|
||||
|
||||
@@ -26761,6 +26827,7 @@ snapshots:
|
||||
turndown@7.2.2:
|
||||
dependencies:
|
||||
'@mixmark-io/domino': 2.2.0
|
||||
optional: true
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
@@ -26844,7 +26911,8 @@ snapshots:
|
||||
|
||||
ufo@1.6.2: {}
|
||||
|
||||
uint8array-extras@1.5.0: {}
|
||||
uint8array-extras@1.5.0:
|
||||
optional: true
|
||||
|
||||
unbox-primitive@1.1.0:
|
||||
dependencies:
|
||||
@@ -27312,6 +27380,37 @@ snapshots:
|
||||
|
||||
webpack-sources@3.3.3: {}
|
||||
|
||||
webpack@5.96.1:
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
'@types/estree': 1.0.8
|
||||
'@webassemblyjs/ast': 1.14.1
|
||||
'@webassemblyjs/wasm-edit': 1.14.1
|
||||
'@webassemblyjs/wasm-parser': 1.14.1
|
||||
acorn: 8.15.0
|
||||
browserslist: 4.28.1
|
||||
chrome-trace-event: 1.0.4
|
||||
enhanced-resolve: 5.19.0
|
||||
es-module-lexer: 1.7.0
|
||||
eslint-scope: 5.1.1
|
||||
events: 3.3.0
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
json-parse-even-better-errors: 2.3.1
|
||||
loader-runner: 4.3.1
|
||||
mime-types: 2.1.35
|
||||
neo-async: 2.6.2
|
||||
schema-utils: 3.3.0
|
||||
tapable: 2.3.0
|
||||
terser-webpack-plugin: 5.3.16(webpack@5.96.1)
|
||||
watchpack: 2.5.1
|
||||
webpack-sources: 3.3.3
|
||||
transitivePeerDependencies:
|
||||
- '@swc/core'
|
||||
- esbuild
|
||||
- uglify-js
|
||||
optional: true
|
||||
|
||||
webpack@5.96.1(esbuild@0.25.0):
|
||||
dependencies:
|
||||
'@types/eslint-scope': 3.7.7
|
||||
|
||||
+10
-2
@@ -159,13 +159,15 @@ import { buildUserPrompt } from "@json-render/core";
|
||||
// Fresh generation
|
||||
buildUserPrompt({ prompt: "create a todo app" });
|
||||
|
||||
// Refinement (patch-only mode)
|
||||
buildUserPrompt({ prompt: "add a toggle", currentSpec: spec });
|
||||
// Refinement with edit modes (default: patch-only)
|
||||
buildUserPrompt({ prompt: "add a toggle", currentSpec: spec, editModes: ["patch", "merge"] });
|
||||
|
||||
// With runtime state
|
||||
buildUserPrompt({ prompt: "show data", state: { todos: [] } });
|
||||
```
|
||||
|
||||
Available edit modes: `"patch"` (RFC 6902 JSON Patch), `"merge"` (RFC 7396 Merge Patch), `"diff"` (unified diff).
|
||||
|
||||
## Spec Validation
|
||||
|
||||
Validate spec structure and auto-fix common issues:
|
||||
@@ -244,6 +246,12 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
|
||||
| `resolvePropValue` | Resolve a single prop expression against data |
|
||||
| `resolveElementProps` | Resolve all prop expressions in an element |
|
||||
| `buildUserPrompt` | Build user prompts with refinement and state context |
|
||||
| `buildEditUserPrompt` | Build user prompt for editing existing specs |
|
||||
| `buildEditInstructions` | Generate prompt section for available edit modes |
|
||||
| `isNonEmptySpec` | Check if spec has root and at least one element |
|
||||
| `deepMergeSpec` | RFC 7396 deep merge (null deletes, arrays replace, objects recurse) |
|
||||
| `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff |
|
||||
| `EditMode` | Type: `"patch" \| "merge" \| "diff"` |
|
||||
| `validateSpec` | Validate spec structure |
|
||||
| `autoFixSpec` | Auto-fix common spec issues |
|
||||
| `createSpecStreamCompiler` | Stream JSONL patches into spec |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
name: yaml
|
||||
description: YAML wire format for json-render with streaming parser, prompt generation, and AI SDK transform. Use when working with @json-render/yaml, YAML-based spec streaming, yaml-spec/yaml-edit fences, or YAML prompt generation.
|
||||
---
|
||||
|
||||
# @json-render/yaml
|
||||
|
||||
YAML wire format for `@json-render/core`. Progressive rendering and surgical edits via streaming YAML.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **YAML wire format**: Alternative to JSONL that uses code fences (`yaml-spec`, `yaml-edit`, `yaml-patch`, `diff`)
|
||||
- **Streaming parser**: Incrementally parses YAML, emits JSON Patch operations via diffing
|
||||
- **Edit modes**: Patch (RFC 6902), merge (RFC 7396), and unified diff
|
||||
- **AI SDK transform**: `TransformStream` that converts YAML fences into json-render patches
|
||||
|
||||
## Generating YAML Prompts
|
||||
|
||||
```typescript
|
||||
import { yamlPrompt } from "@json-render/yaml";
|
||||
import { catalog } from "./catalog";
|
||||
|
||||
// Standalone mode (LLM outputs only YAML)
|
||||
const systemPrompt = yamlPrompt(catalog, {
|
||||
mode: "standalone",
|
||||
editModes: ["merge"],
|
||||
customRules: ["Always use dark theme"],
|
||||
});
|
||||
|
||||
// Inline mode (LLM responds conversationally, wraps YAML in fences)
|
||||
const chatPrompt = yamlPrompt(catalog, { mode: "inline" });
|
||||
```
|
||||
|
||||
Options:
|
||||
|
||||
- `system` (string) — Custom system message intro
|
||||
- `mode` ("standalone" | "inline") — Output mode, default "standalone"
|
||||
- `customRules` (string[]) — Additional rules appended to prompt
|
||||
- `editModes` (EditMode[]) — Edit modes to document, default ["merge"]
|
||||
|
||||
## AI SDK Transform
|
||||
|
||||
Use `pipeYamlRender` as a drop-in replacement for `pipeJsonRender`:
|
||||
|
||||
```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 });
|
||||
```
|
||||
|
||||
For multi-turn edits, pass the previous spec:
|
||||
|
||||
```typescript
|
||||
pipeYamlRender(result.toUIMessageStream(), {
|
||||
previousSpec: currentSpec,
|
||||
});
|
||||
```
|
||||
|
||||
The transform recognizes four fence types:
|
||||
|
||||
- `yaml-spec` — Full spec, parsed progressively line-by-line
|
||||
- `yaml-edit` — Partial YAML deep-merged with current spec (RFC 7396)
|
||||
- `yaml-patch` — RFC 6902 JSON Patch lines
|
||||
- `diff` — Unified diff applied to serialized spec
|
||||
|
||||
## Streaming Parser (Low-Level)
|
||||
|
||||
```typescript
|
||||
import { createYamlStreamCompiler } from "@json-render/yaml";
|
||||
|
||||
const compiler = createYamlStreamCompiler<Spec>();
|
||||
|
||||
// Feed chunks as they arrive from any source
|
||||
const { result, newPatches } = compiler.push("root: main\n");
|
||||
compiler.push("elements:\n main:\n type: Card\n");
|
||||
|
||||
// Flush remaining data at end of stream
|
||||
const { result: final } = compiler.flush();
|
||||
|
||||
// Reset for next stream (optionally with initial state)
|
||||
compiler.reset({ root: "main", elements: {} });
|
||||
```
|
||||
|
||||
Methods: `push(chunk)`, `flush()`, `getResult()`, `getPatches()`, `reset(initial?)`
|
||||
|
||||
## Edit Modes (from @json-render/core)
|
||||
|
||||
The YAML package uses the universal edit mode system from core:
|
||||
|
||||
```typescript
|
||||
import { buildEditInstructions, buildEditUserPrompt } from "@json-render/core";
|
||||
import type { EditMode } from "@json-render/core";
|
||||
|
||||
// Generate edit instructions for YAML format
|
||||
const instructions = buildEditInstructions({ modes: ["merge", "patch"] }, "yaml");
|
||||
|
||||
// Build user prompt with current spec context
|
||||
const userPrompt = buildEditUserPrompt({
|
||||
prompt: "Change the title to Dashboard",
|
||||
currentSpec: spec,
|
||||
config: { modes: ["merge"] },
|
||||
format: "yaml",
|
||||
serializer: (s) => yamlStringify(s, { indent: 2 }).trimEnd(),
|
||||
});
|
||||
```
|
||||
|
||||
## Fence Constants
|
||||
|
||||
For custom parsing, use the exported constants:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
YAML_SPEC_FENCE, // "```yaml-spec"
|
||||
YAML_EDIT_FENCE, // "```yaml-edit"
|
||||
YAML_PATCH_FENCE, // "```yaml-patch"
|
||||
DIFF_FENCE, // "```diff"
|
||||
FENCE_CLOSE, // "```"
|
||||
} from "@json-render/yaml";
|
||||
```
|
||||
|
||||
## Key Exports
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `yamlPrompt` | Generate YAML system prompt from catalog |
|
||||
| `createYamlTransform` | AI SDK TransformStream for YAML fences |
|
||||
| `pipeYamlRender` | Convenience pipe wrapper (replaces `pipeJsonRender`) |
|
||||
| `createYamlStreamCompiler` | Streaming YAML parser with patch emission |
|
||||
| `YAML_SPEC_FENCE` | Fence constant for yaml-spec |
|
||||
| `YAML_EDIT_FENCE` | Fence constant for yaml-edit |
|
||||
| `YAML_PATCH_FENCE` | Fence constant for yaml-patch |
|
||||
| `DIFF_FENCE` | Fence constant for diff |
|
||||
| `FENCE_CLOSE` | Fence close constant |
|
||||
| `diffToPatches` | Re-export: object diff to JSON Patch |
|
||||
| `deepMergeSpec` | Re-export: RFC 7396 deep merge |
|
||||
Reference in New Issue
Block a user