diff --git a/.changeset/config.json b/.changeset/config.json index 921a7843..e67a77d1 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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": [], diff --git a/README.md b/README.md index b270c266..d2b03ecc 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/web/app/(main)/docs/api/core/page.mdx b/apps/web/app/(main)/docs/api/core/page.mdx index 139816fe..c0198778 100644 --- a/apps/web/app/(main)/docs/api/core/page.mdx +++ b/apps/web/app/(main)/docs/api/core/page.mdx @@ -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 { @@ -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 | 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, + patch: Record +): Record + +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, + newObj: Record, + 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. diff --git a/apps/web/app/(main)/docs/api/yaml/page.mdx b/apps/web/app/(main)/docs/api/yaml/page.mdx new file mode 100644 index 00000000..bd4c7b68 --- /dev/null +++ b/apps/web/app/(main)/docs/api/yaml/page.mdx @@ -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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
OptionTypeDefaultDescription
systemstring{'\"You are a UI generator that outputs YAML.\"'}Custom system message intro
mode{'\"standalone\" | \"inline\"'}{'\"standalone\"'}Standalone outputs only YAML; inline allows conversational responses with embedded YAML fences
customRules{'string[]'}{'[]'}Additional rules appended to the prompt
editModes{'EditMode[]'}{'[\"merge\"]'}Edit modes to document in the prompt (patch, merge, diff)
+ +## 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 +``` + +Recognized fence types: + +- {'```yaml-spec'} -- Full YAML spec, parsed progressively +- {'```yaml-edit'} -- Partial YAML, deep-merged with current spec +- {'```yaml-patch'} -- RFC 6902 JSON Patch lines +- {'```diff'} -- 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( + stream: ReadableStream, + options?: YamlTransformOptions +): ReadableStream +``` + +```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( + initial?: Partial +): YamlStreamCompiler +``` + +```typescript +import { createYamlStreamCompiler } from "@json-render/yaml"; + +const compiler = createYamlStreamCompiler(); + +compiler.push("root: main\n"); +compiler.push("elements:\n main:\n type: Card\n"); + +const { result, newPatches } = compiler.flush(); +``` + +### YamlStreamCompiler + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
MethodReturnsDescription
push(chunk){'{ result: T; newPatches: JsonPatch[] }'}Push a chunk of text, returns current result and new patches
flush(){'{ result: T; newPatches: JsonPatch[] }'}Flush remaining buffer, return final result
getResult()TGet the current compiled result
getPatches(){'JsonPatch[]'}Get all patches applied so far
reset(initial?)voidReset to initial state
+ +## Fence Constants + +Exported string constants for fence detection in custom parsers: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ConstantValue
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 + +Generate RFC 6902 JSON Patch operations that transform one object into another. + +```typescript +function diffToPatches( + oldObj: Record, + newObj: Record, + basePath?: string +): JsonPatch[] +``` + +### deepMergeSpec + +Deep-merge with RFC 7396 semantics: `null` deletes, arrays replace, objects recurse. + +```typescript +function deepMergeSpec( + base: Record, + patch: Record +): Record +``` diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index d0dc5107..ae4641c3 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -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 ". 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 ". 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. diff --git a/apps/web/app/api/generate/route.ts b/apps/web/app/api/generate/route.ts index b4740368..fd0c1bba 100644 --- a/apps/web/app/api/generate/route.ts +++ b/apps/web/app/api/generate/route.ts @@ -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(); }, diff --git a/apps/web/components/code-block.tsx b/apps/web/components/code-block.tsx index e97d9a86..e4fd219b 100644 --- a/apps/web/components/code-block.tsx +++ b/apps/web/components/code-block.tsx @@ -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; } diff --git a/apps/web/components/playground.tsx b/apps/web/components/playground.tsx index 51c96c75..e98261b2 100644 --- a/apps/web/components/playground.tsx +++ b/apps/web/components/playground.tsx @@ -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>; + showClear: boolean; + onClear: () => void; +}) { + return ( +
+
+ {(["jsonl", "yaml"] as const).map((f) => ( + + ))} +
+
+ {(["patch", "merge", "diff"] as const).map((m) => ( + + ))} +
+ {showClear && ( + + )} +
+ ); } /** @@ -100,13 +179,15 @@ export function Playground() { null, ); const [inputValue, setInputValue] = useState(""); - const [activeTab, setActiveTab] = useState("json"); + const [activeTab, setActiveTab] = useState("spec"); const [catalogSection, setCatalogSection] = useState< "components" | "actions" >("components"); const [renderView, setRenderView] = useState("preview"); const [mobileView, setMobileView] = useState("preview"); const [versionsSheetOpen, setVersionsSheetOpen] = useState(false); + const [format, setFormat] = useState("jsonl"); + const [editModes, setEditModes] = useState(["patch"]); const inputRef = useRef(null); const mobileInputRef = useRef(null); const versionsEndRef = useRef(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[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 = {}; - 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 = (
@@ -392,9 +491,15 @@ ${jsx} )}
{version.usage && ( -
+
- {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` + : ""}
)} @@ -425,20 +530,18 @@ ${jsx} autoFocus />
- {versions.length > 0 ? ( - - ) : ( -
- )} + 0} + onClear={() => { + setVersions([]); + setSelectedVersionId(null); + clear(); + }} + /> {isStreaming ? ( ), )} @@ -679,7 +782,7 @@ ${jsx} currentRawLines.length > 0 ? ( @@ -691,7 +794,12 @@ ${jsx} ) : activeTab === "nested" ? ( ) : ( - + )}
@@ -790,7 +898,7 @@ ${jsx} : 0} {/* Code tabs */} - {(["json", "visual", "nested", "stream", "catalog"] as const).map( + {(["spec", "visual", "nested", "stream", "catalog"] as const).map( (tab) => ( ), )} @@ -982,7 +1090,7 @@ ${jsx} currentRawLines.length > 0 ? ( @@ -998,8 +1106,13 @@ ${jsx} fillHeight hideCopyButton /> - ) : mobileView === "json" ? ( - + ) : mobileView === "spec" ? ( + ) : mobileView === "preview" ? ( currentTree && currentTree.root ? (
@@ -1075,20 +1188,18 @@ ${jsx} rows={2} />
- {versions.length > 0 ? ( - - ) : ( -
- )} + 0} + onClear={() => { + setVersions([]); + setSelectedVersionId(null); + clear(); + }} + /> {isStreaming ? (
{version.usage && ( -
+
- {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` + : ""}
)} diff --git a/apps/web/lib/docs-navigation.ts b/apps/web/lib/docs-navigation.ts index 059e25ba..172d579c 100644 --- a/apps/web/lib/docs-navigation.ts +++ b/apps/web/lib/docs-navigation.ts @@ -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" }, ], }, ]; diff --git a/apps/web/lib/page-titles.ts b/apps/web/lib/page-titles.ts index 21a4bea8..679408c7 100644 --- a/apps/web/lib/page-titles.ts +++ b/apps/web/lib/page-titles.ts @@ -58,6 +58,7 @@ export const PAGE_TITLES: Record = { "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", }; /** diff --git a/apps/web/lib/spec-patch.ts b/apps/web/lib/spec-patch.ts new file mode 100644 index 00000000..cfff7769 --- /dev/null +++ b/apps/web/lib/spec-patch.ts @@ -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; + return; + } + if (path.startsWith("/state/")) { + if (!newSpec.state) newSpec.state = {}; + setByPath( + newSpec.state as Record, + 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; + 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, + "/" + 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, + 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, + "/" + 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, + path.slice("/state".length), + ); + } + return getByPath(spec as unknown as Record, 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; +} diff --git a/apps/web/lib/use-playground-stream.ts b/apps/web/lib/use-playground-stream.ts new file mode 100644 index 00000000..1df86fe5 --- /dev/null +++ b/apps/web/lib/use-playground-stream.ts @@ -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) => Promise; + clear: () => void; +} + +// ── JSONL helpers ── + +type ParsedLine = + | { type: "patch"; patch: JsonPatch } + | { type: "usage"; usage: TokenUsage } + | { type: "json-edit"; mergeObj: Record } + | 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(null); + const [isStreaming, setIsStreaming] = useState(false); + const [error, setError] = useState(null); + const [usage, setUsage] = useState(null); + const [rawLines, setRawLines] = useState([]); + const rawLinesRef = useRef([]); + const abortControllerRef = useRef(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) => { + 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>(); + 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, + editObj as Record, + ); + const patches = diffToPatches( + currentSpec as unknown as Record, + 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, + parsed as Record, + ); + 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, + parsed as Record, + ); + 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, + result.mergeObj, + ); + const patches = diffToPatches( + currentSpec as unknown as Record, + 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, + result.mergeObj, + ); + const patches = diffToPatches( + currentSpec as unknown as Record, + 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 }; +} diff --git a/apps/web/package.json b/apps/web/package.json index d731e4fb..b1a3f3ef 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/packages/core/README.md b/packages/core/README.md index faf66523..a9e70090 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -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: diff --git a/packages/core/src/diff.ts b/packages/core/src/diff.ts new file mode 100644 index 00000000..83003ae7 --- /dev/null +++ b/packages/core/src/diff.ts @@ -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 { + 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, + newObj: Record, + 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; +} diff --git a/packages/core/src/edit-modes.ts b/packages/core/src/edit-modes.ts new file mode 100644 index 00000000..4e8827df --- /dev/null +++ b/packages/core/src/edit-modes.ts @@ -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; + 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"); +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 23c43dff..f1b2ec19 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/merge.ts b/packages/core/src/merge.ts new file mode 100644 index 00000000..b16848b1 --- /dev/null +++ b/packages/core/src/merge.ts @@ -0,0 +1,41 @@ +function isPlainObject(value: unknown): value is Record { + 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, + patch: Record, +): Record { + const result: Record = { ...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; +} diff --git a/packages/core/src/prompt.ts b/packages/core/src/prompt.ts index da165aac..fb6515e9 100644 --- a/packages/core/src/prompt.ts +++ b/packages/core/src/prompt.ts @@ -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 | 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; - 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 --- diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 05eee89f..c5824454 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -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 = diff --git a/packages/yaml/README.md b/packages/yaml/README.md new file mode 100644 index 00000000..30891ce0 --- /dev/null +++ b/packages/yaml/README.md @@ -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(); + +// 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` 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 diff --git a/packages/yaml/package.json b/packages/yaml/package.json new file mode 100644 index 00000000..f53a4901 --- /dev/null +++ b/packages/yaml/package.json @@ -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" + } +} diff --git a/packages/yaml/src/diff.test.ts b/packages/yaml/src/diff.test.ts new file mode 100644 index 00000000..aa5678c3 --- /dev/null +++ b/packages/yaml/src/diff.test.ts @@ -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, + newObj as Record, + ); + 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, + }); + }); +}); diff --git a/packages/yaml/src/diff.ts b/packages/yaml/src/diff.ts new file mode 100644 index 00000000..ca49a0d0 --- /dev/null +++ b/packages/yaml/src/diff.ts @@ -0,0 +1 @@ +export { diffToPatches } from "@json-render/core"; diff --git a/packages/yaml/src/index.ts b/packages/yaml/src/index.ts new file mode 100644 index 00000000..b18fe3ce --- /dev/null +++ b/packages/yaml/src/index.ts @@ -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"; diff --git a/packages/yaml/src/merge.test.ts b/packages/yaml/src/merge.test.ts new file mode 100644 index 00000000..d4413b9d --- /dev/null +++ b/packages/yaml/src/merge.test.ts @@ -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>)["main"], + ).toEqual({ + type: "Card", + props: { title: "Updated Dashboard" }, + children: ["metric-1", "chart-1"], + }); + expect( + (result.elements as Record>)["metric-1"], + ).toEqual({ + type: "Metric", + props: { label: "Revenue", value: "$1M" }, + children: [], + }); + expect( + (result.elements as Record>)["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" } }); + }); +}); diff --git a/packages/yaml/src/merge.ts b/packages/yaml/src/merge.ts new file mode 100644 index 00000000..87e6ac3f --- /dev/null +++ b/packages/yaml/src/merge.ts @@ -0,0 +1 @@ +export { deepMergeSpec } from "@json-render/core"; diff --git a/packages/yaml/src/parser.test.ts b/packages/yaml/src/parser.test.ts new file mode 100644 index 00000000..ed720e29 --- /dev/null +++ b/packages/yaml/src/parser.test.ts @@ -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>; + 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>; + expect(elements.main).toBeDefined(); + expect(elements["metric-1"]).toBeDefined(); + expect((elements["metric-1"]!.props as Record).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"); + }); +}); diff --git a/packages/yaml/src/parser.ts b/packages/yaml/src/parser.ts new file mode 100644 index 00000000..159614c9 --- /dev/null +++ b/packages/yaml/src/parser.ts @@ -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 { + /** 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): 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(); + * compiler.push("root: main\n"); + * compiler.push("elements:\n main:\n type: Card\n"); + * const { result } = compiler.flush(); + * ``` + */ +export function createYamlStreamCompiler< + T extends Record = Record, +>(initial?: Partial): YamlStreamCompiler { + let accumulated = ""; + let snapshot: Record = initial + ? { ...initial } + : ({} as Record); + 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, + ); + + if (patches.length > 0) { + snapshot = structuredClone(parsed as Record); + 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): void { + accumulated = ""; + snapshot = newInitial + ? { ...newInitial } + : ({} as Record); + result = { ...snapshot } as T; + allPatches.length = 0; + }, + }; +} diff --git a/packages/yaml/src/prompt.test.ts b/packages/yaml/src/prompt.test.ts new file mode 100644 index 00000000..278d8020 --- /dev/null +++ b/packages/yaml/src/prompt.test.ts @@ -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."); + }); +}); diff --git a/packages/yaml/src/prompt.ts b/packages/yaml/src/prompt.ts new file mode 100644 index 00000000..6ff75a3c --- /dev/null +++ b/packages/yaml/src/prompt.ts @@ -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; +} + +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; +} + +// ── 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); + } 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"; + } + case "ZodObject": + case "object": { + const shape = + typeof def.shape === "function" + ? (def.shape as () => Record)() + : (def.shape as Record); + 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 { + 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)() + : (zodDef.shape as Record); + if (!shape) return {}; + const result: Record = {}; + 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); + 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, + 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).components as + | Record + | 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).actions as + | Record + | 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": , "$then": , "$else": }', + ); + lines.push( + '4. Template: { "$template": "Hello, ${/name}!" } — interpolates state references.', + ); + lines.push(""); + + // ── $computed (only if catalog has functions) ── + + const catalogFunctions = (catalog.data as Record).functions; + if (catalogFunctions && Object.keys(catalogFunctions).length > 0) { + lines.push( + '5. Computed: { "$computed": "", "args": { "key": } }', + ); + lines.push(" Available functions:"); + for (const name of Object.keys( + catalogFunctions as Record, + )) { + 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"); +} diff --git a/packages/yaml/src/transform.test.ts b/packages/yaml/src/transform.test.ts new file mode 100644 index 00000000..bad6172b --- /dev/null +++ b/packages/yaml/src/transform.test.ts @@ -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[0], +): Promise { + 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({ + 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({ + 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", + }); + }); +}); diff --git a/packages/yaml/src/transform.ts b/packages/yaml/src/transform.ts new file mode 100644 index 00000000..fec1c38d --- /dev/null +++ b/packages/yaml/src/transform.ts @@ -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 { + 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>(); + + // The "current spec" — built up during yaml-spec, used as base for yaml-edit + let currentSpec: Record = options?.previousSpec + ? structuredClone( + options.previousSpec as unknown as Record, + ) + : {}; + + // ── Text block helpers (same pattern as createJsonRenderTransform) ── + + function closeTextBlock( + controller: TransformStreamDefaultController, + ) { + if (inTextBlock) { + controller.enqueue({ type: "text-end", id: currentTextId }); + inTextBlock = false; + } + } + + function ensureTextBlock( + controller: TransformStreamDefaultController, + ) { + if (!inTextBlock) { + textIdCounter++; + currentTextId = String(textIdCounter); + controller.enqueue({ type: "text-start", id: currentTextId }); + inTextBlock = true; + } + } + + function emitTextDelta( + delta: string, + controller: TransformStreamDefaultController, + ) { + ensureTextBlock(controller); + controller.enqueue({ type: "text-delta", id: currentTextId, delta }); + } + + function emitPatch( + patch: JsonPatch, + controller: TransformStreamDefaultController, + ) { + closeTextBlock(controller); + controller.enqueue({ + type: SPEC_DATA_PART_TYPE, + data: { type: "patch", patch }, + }); + } + + function emitPatches( + patches: JsonPatch[], + controller: TransformStreamDefaultController, + ) { + 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, + ) { + 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, + ) { + try { + const editObj = parse(yamlAccumulated); + if (editObj && typeof editObj === "object" && !Array.isArray(editObj)) { + const merged = deepMergeSpec( + currentSpec, + editObj as Record, + ); + 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, + ) { + const { newPatches } = compiler.flush(); + if (newPatches.length > 0) { + emitPatches(newPatches, controller); + } + currentSpec = structuredClone( + compiler.getResult() as Record, + ); + } + + /** + * Finalise a yaml-patch block: parse each accumulated line as an + * RFC 6902 JSON Patch operation and emit directly. + */ + function finaliseYamlPatch( + controller: TransformStreamDefaultController, + ) { + 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 = 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; + } + 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 = 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; + } + 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, + ) { + 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, + ); + if (patches.length > 0) { + currentSpec = newSpec as Record; + emitPatches(patches, controller); + } + } + } catch { + // Diff apply or reparse failed + } + } + + // ── Line processing ── + + function processCompleteLine( + line: string, + controller: TransformStreamDefaultController, + ) { + 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, + ) { + if (!lineBuffer) return; + + if (fenceMode !== null) { + processCompleteLine(lineBuffer, controller); + } else { + emitTextDelta(lineBuffer, controller); + } + lineBuffer = ""; + buffering = false; + } + + // ── TransformStream ── + + return new TransformStream({ + 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( + stream: ReadableStream, + options?: YamlTransformOptions, +): ReadableStream { + return stream.pipeThrough( + createYamlTransform(options) as unknown as TransformStream, + ); +} diff --git a/packages/yaml/tsconfig.json b/packages/yaml/tsconfig.json new file mode 100644 index 00000000..670b9f91 --- /dev/null +++ b/packages/yaml/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@internal/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/yaml/tsup.config.ts b/packages/yaml/tsup.config.ts new file mode 100644 index 00000000..d94143eb --- /dev/null +++ b/packages/yaml/tsup.config.ts @@ -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"], +}); diff --git a/packages/yaml/vitest.config.ts b/packages/yaml/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/packages/yaml/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 700e309b..b2db2e33 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/skills/core/SKILL.md b/skills/core/SKILL.md index d283817d..ca44cd8b 100644 --- a/skills/core/SKILL.md +++ b/skills/core/SKILL.md @@ -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 | diff --git a/skills/yaml/SKILL.md b/skills/yaml/SKILL.md new file mode 100644 index 00000000..4d2c36d2 --- /dev/null +++ b/skills/yaml/SKILL.md @@ -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(); + +// 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 |