From 124e876f511e9246960936c04ed056b7f985f220 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 13 Mar 2026 11:07:19 -0500 Subject: [PATCH 1/5] add yaml wire format and universal edit modes **Summary** - Add `@json-render/yaml` package with streaming parser, YAML prompt generation, and AI SDK transform - Add universal edit modes (patch, merge, diff) to `@json-render/core`, usable across both JSONL and YAML formats - Integrate YAML support and edit mode selection into the web playground with format toggle, token usage display, and prompt caching --- .changeset/config.json | 3 +- apps/web/app/api/generate/route.ts | 109 ++++- apps/web/components/code-block.tsx | 4 +- apps/web/components/playground.tsx | 241 +++++++--- apps/web/lib/use-playground-stream.ts | 636 ++++++++++++++++++++++++++ apps/web/package.json | 4 +- packages/core/src/diff.ts | 78 ++++ packages/core/src/edit-modes.ts | 316 +++++++++++++ packages/core/src/index.ts | 12 + packages/core/src/merge.ts | 41 ++ packages/core/src/prompt.ts | 43 +- packages/core/src/schema.ts | 10 + packages/yaml/package.json | 59 +++ packages/yaml/src/diff.test.ts | 109 +++++ packages/yaml/src/diff.ts | 1 + packages/yaml/src/index.ts | 16 + packages/yaml/src/merge.test.ts | 109 +++++ packages/yaml/src/merge.ts | 1 + packages/yaml/src/parser.test.ts | 149 ++++++ packages/yaml/src/parser.ts | 110 +++++ packages/yaml/src/prompt.test.ts | 110 +++++ packages/yaml/src/prompt.ts | 582 +++++++++++++++++++++++ packages/yaml/src/transform.test.ts | 161 +++++++ packages/yaml/src/transform.ts | 453 ++++++++++++++++++ packages/yaml/tsconfig.json | 9 + packages/yaml/tsup.config.ts | 10 + pnpm-lock.yaml | 369 ++++++++++++++- 27 files changed, 3617 insertions(+), 128 deletions(-) create mode 100644 apps/web/lib/use-playground-stream.ts create mode 100644 packages/core/src/diff.ts create mode 100644 packages/core/src/edit-modes.ts create mode 100644 packages/core/src/merge.ts create mode 100644 packages/yaml/package.json create mode 100644 packages/yaml/src/diff.test.ts create mode 100644 packages/yaml/src/diff.ts create mode 100644 packages/yaml/src/index.ts create mode 100644 packages/yaml/src/merge.test.ts create mode 100644 packages/yaml/src/merge.ts create mode 100644 packages/yaml/src/parser.test.ts create mode 100644 packages/yaml/src/parser.ts create mode 100644 packages/yaml/src/prompt.test.ts create mode 100644 packages/yaml/src/prompt.ts create mode 100644 packages/yaml/src/transform.test.ts create mode 100644 packages/yaml/src/transform.ts create mode 100644 packages/yaml/tsconfig.json create mode 100644 packages/yaml/tsup.config.ts 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/apps/web/app/api/generate/route.ts b/apps/web/app/api/generate/route.ts index b4740368..97d69843 100644 --- a/apps/web/app/api/generate/route.ts +++ b/apps/web/app/api/generate/route.ts @@ -1,32 +1,80 @@ 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 } 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 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 + ); +} + +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 +96,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 +132,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 +139,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..c2ed5ab2 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,12 @@ 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); } /** @@ -100,13 +112,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 +138,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 +155,7 @@ export function Playground() { generatingVersionIdRef.current = null; } }, - } as Parameters[0]); + }); // Get the selected version const selectedVersion = versions.find((v) => v.id === selectedVersionId); @@ -215,6 +230,7 @@ export function Playground() { status: "generating", usage: null, rawLines: [], + format, }; generatingVersionIdRef.current = newVersionId; @@ -250,9 +266,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 +298,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 +344,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 +424,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 +463,58 @@ ${jsx} autoFocus />
- {versions.length > 0 ? ( - - ) : ( -
- )} +
+
+ {(["jsonl", "yaml"] as const).map((f) => ( + + ))} +
+
+ {(["patch", "merge", "diff"] as const).map((m) => ( + + ))} +
+ {versions.length > 0 && ( + + )} +
{isStreaming ? ( ), )} @@ -679,7 +755,7 @@ ${jsx} currentRawLines.length > 0 ? ( @@ -691,7 +767,12 @@ ${jsx} ) : activeTab === "nested" ? ( ) : ( - + )}
@@ -790,7 +871,7 @@ ${jsx} : 0} {/* Code tabs */} - {(["json", "visual", "nested", "stream", "catalog"] as const).map( + {(["spec", "visual", "nested", "stream", "catalog"] as const).map( (tab) => ( ), )} @@ -982,7 +1063,7 @@ ${jsx} currentRawLines.length > 0 ? ( @@ -998,8 +1079,13 @@ ${jsx} fillHeight hideCopyButton /> - ) : mobileView === "json" ? ( - + ) : mobileView === "spec" ? ( + ) : mobileView === "preview" ? ( currentTree && currentTree.root ? (
@@ -1075,20 +1161,58 @@ ${jsx} rows={2} />
- {versions.length > 0 ? ( - - ) : ( -
- )} +
+
+ {(["jsonl", "yaml"] as const).map((f) => ( + + ))} +
+
+ {(["patch", "merge", "diff"] as const).map((m) => ( + + ))} +
+ {versions.length > 0 && ( + + )} +
{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/use-playground-stream.ts b/apps/web/lib/use-playground-stream.ts new file mode 100644 index 00000000..1263f006 --- /dev/null +++ b/apps/web/lib/use-playground-stream.ts @@ -0,0 +1,636 @@ +"use client"; + +import { useState, useCallback, useRef, useEffect } from "react"; +import type { Spec, JsonPatch, EditMode } from "@json-render/core"; +import { + setByPath, + getByPath, + removeByPath, + deepMergeSpec, + diffToPatches, +} from "@json-render/core"; +import { parse as yamlParse, stringify as yamlStringify } from "yaml"; +import { applyPatch as applyUnifiedDiff } from "diff"; +import { createYamlStreamCompiler } from "@json-render/yaml"; + +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 { __json_edit: _, ...mergeObj } = parsed; + return { type: "json-edit", mergeObj }; + } + return { type: "patch", patch: parsed as JsonPatch }; + } catch { + return null; + } +} + +// ── Spec patch helpers ── + +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; + } + } + } +} + +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) { + const { [elementKey]: _, ...rest } = newSpec.elements; + newSpec.elements = rest; + } 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; + } + } + } +} + +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); +} + +function normalizeSpec(spec: Spec): void { + // state: null → undefined (YAML parses "state:" with no value as null) + 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]; + } + } +} + +function applyPatch(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; +} + +// ── YAML fence parsing state machine ── + +const YAML_SPEC_FENCE = "```yaml-spec"; +const YAML_EDIT_FENCE = "```yaml-edit"; +const YAML_PATCH_FENCE = "```yaml-patch"; +const DIFF_FENCE = "```diff"; +const FENCE_CLOSE = "```"; + +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 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); + 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 + } + } + + setRawLines((prev) => [...prev, 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 = applyPatch(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 = applyPatch(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 = applyPatch(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 = applyPatch(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 = applyPatch(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"; + } + } + } + + // 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 = applyPatch(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 = applyPatch(currentSpec, patch); + } + setSpec({ ...currentSpec }); + } + } + } catch { + // Diff apply failed + } + jsonlDiffState = "outside"; + continue; + } + + if (jsonlDiffState === "diff") { + jsonlDiffAccumulated += line + "\n"; + setRawLines((prev) => [...prev, 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 = applyPatch(currentSpec, patch); + } + setRawLines((prev) => [...prev, trimmed]); + setSpec({ ...currentSpec }); + } else { + setRawLines((prev) => [...prev, trimmed]); + currentSpec = applyPatch(currentSpec, result.patch); + setSpec({ ...currentSpec }); + } + } + } + + 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 = applyPatch(currentSpec, patch); + } + setRawLines((prev) => [...prev, trimmed]); + setSpec({ ...currentSpec }); + } else { + setRawLines((prev) => [...prev, trimmed]); + currentSpec = applyPatch(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/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..f103a8f4 --- /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"); +} + +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..d12226ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -178,3 +178,15 @@ 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 } 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..33fe7977 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 } from "./edit-modes"; /** * Options for building a user prompt. @@ -12,6 +14,8 @@ 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[]; } /** @@ -28,21 +32,12 @@ function isNonEmptySpec(spec: unknown): spec is Spec { ); } -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 +48,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 +63,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/package.json b/packages/yaml/package.json new file mode 100644 index 00000000..e71d1789 --- /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": "^3.2.1", + "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..900aba62 --- /dev/null +++ b/packages/yaml/src/index.ts @@ -0,0 +1,16 @@ +// 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 } 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..aad778ae --- /dev/null +++ b/packages/yaml/src/transform.ts @@ -0,0 +1,453 @@ +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"; + +const YAML_SPEC_FENCE = "```yaml-spec"; +const YAML_EDIT_FENCE = "```yaml-edit"; +const YAML_PATCH_FENCE = "```yaml-patch"; +const DIFF_FENCE = "```diff"; +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 { + const trimmed = lineBuffer.trim(); + if (trimmed) { + emitTextDelta(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..5e7813e0 --- /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 --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 700e309b..ee25d5f2 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: ^3.2.1 + version: 3.2.4(@types/debug@4.1.12)(@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': @@ -6390,9 +6424,23 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 vue: ^3.2.25 + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.17': resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.17': resolution: {integrity: sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==} peerDependencies: @@ -6404,18 +6452,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.17': resolution: {integrity: sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.17': resolution: {integrity: sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.17': resolution: {integrity: sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.17': resolution: {integrity: sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.17': resolution: {integrity: sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==} @@ -7063,6 +7126,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -7098,6 +7165,10 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -7518,6 +7589,10 @@ packages: babel-plugin-macros: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -9428,6 +9503,9 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@3.14.2: resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} hasBin: true @@ -9768,6 +9846,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -10625,6 +10706,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} @@ -11790,6 +11875,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@13.11.0: resolution: {integrity: sha512-yPxVJxUzP1QHhHeFnYjJl48QwDS1+5befcL7ju7+t+i88D5r0rbsL+GkCCS6zgcU+TiV5bF9eMGcKyJfLf8BZQ==} engines: {node: '>=12.*'} @@ -12029,10 +12117,22 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + tldts-core@7.0.19: resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==} @@ -12480,6 +12580,11 @@ packages: resolution: {integrity: sha512-t20zYkrSf868+j/p31cRIGN28Phrjm3nRSLR2fyc2tiWi4cZGVdv68yNlwnIINTkMTmPoMiSlc0OadaO7DXZaQ==} engines: {node: '>= 6'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite-plugin-singlefile@2.3.0: resolution: {integrity: sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==} engines: {node: '>18.0.0'} @@ -12585,6 +12690,34 @@ packages: vite: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.17: resolution: {integrity: sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -13767,7 +13900,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 +15385,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 +15427,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: @@ -18584,8 +18719,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': {} @@ -19087,6 +19224,14 @@ snapshots: vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) vue: 3.5.29(typescript@5.9.3) + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.17': dependencies: '@standard-schema/spec': 1.1.0 @@ -19096,6 +19241,15 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.3) + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.17(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.17 @@ -19114,23 +19268,49 @@ snapshots: msw: 2.12.10(@types/node@22.19.6)(typescript@5.9.3) vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.17': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.17': dependencies: '@vitest/utils': 4.0.17 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + '@vitest/snapshot@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 magic-string: 0.30.21 pathe: 2.0.3 + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.17': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.17': dependencies: '@vitest/pretty-format': 4.0.17 @@ -19429,7 +19609,8 @@ snapshots: alien-signals@3.1.2: {} - amdefine@1.0.1: {} + amdefine@1.0.1: + optional: true anser@1.4.10: {} @@ -19927,6 +20108,14 @@ snapshots: ccount@2.0.1: {} + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@2.4.2: @@ -19954,6 +20143,8 @@ snapshots: chardet@2.1.1: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -20087,6 +20278,7 @@ snapshots: commander@2.8.1: dependencies: graceful-readlink: 1.0.1 + optional: true commander@4.1.1: {} @@ -20112,6 +20304,7 @@ snapshots: dependencies: amdefine: 1.0.1 commander: 2.8.1 + optional: true concat-map@0.0.1: {} @@ -20335,6 +20528,8 @@ snapshots: dedent@1.7.1: {} + deep-eql@5.0.2: {} + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -21505,6 +21700,7 @@ snapshots: fast-xml-parser@5.3.5: dependencies: strnum: 2.1.2 + optional: true fastq@1.20.1: dependencies: @@ -21555,6 +21751,7 @@ snapshots: uint8array-extras: 1.5.0 transitivePeerDependencies: - supports-color + optional: true fill-range@7.1.1: dependencies: @@ -21836,7 +22033,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 +22344,8 @@ snapshots: ini@1.3.8: {} - ini@6.0.0: {} + ini@6.0.0: + optional: true inline-style-parser@0.2.7: {} @@ -22933,6 +23132,8 @@ snapshots: js-tokens@4.0.0: {} + js-tokens@9.0.1: {} + js-yaml@3.14.2: dependencies: argparse: 1.0.10 @@ -23065,6 +23266,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true keyv@4.5.4: dependencies: @@ -23281,6 +23483,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + loupe@3.2.1: {} + lru-cache@10.4.3: {} lru-cache@11.2.4: {} @@ -24061,7 +24265,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 +24660,8 @@ snapshots: pako@1.0.11: {} - papaparse@5.5.3: {} + papaparse@5.5.3: + optional: true parent-module@1.0.1: dependencies: @@ -24538,6 +24744,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + peberminta@0.9.0: {} pend@1.2.0: {} @@ -24749,6 +24957,7 @@ snapshots: transitivePeerDependencies: - bufferutil - utf-8-validate + optional: true qrcode-terminal@0.11.0: {} @@ -24919,7 +25128,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 +26235,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 +26293,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: @@ -26287,16 +26500,22 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + stripe@13.11.0: dependencies: '@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 +26704,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 @@ -26561,8 +26790,14 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + tldts-core@7.0.19: {} tldts@7.0.19: @@ -26582,6 +26817,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 +26997,7 @@ snapshots: turndown@7.2.2: dependencies: '@mixmark-io/domino': 2.2.0 + optional: true tw-animate-css@1.4.0: {} @@ -26844,7 +27081,8 @@ snapshots: ufo@1.6.2: {} - uint8array-extras@1.5.0: {} + uint8array-extras@1.5.0: + optional: true unbox-primitive@1.1.0: dependencies: @@ -27099,6 +27337,27 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + vite-node@3.2.4(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-plugin-singlefile@2.3.0(rollup@4.55.1)(vite@6.4.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: micromatch: 4.0.8 @@ -27156,6 +27415,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest@3.2.4(@types/debug@4.1.12)(@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): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.3))(vite@7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@22.19.6)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 22.19.6 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@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.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.17 @@ -27312,6 +27614,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 From f77a01346491068e8f7f1529e642491cee8964d3 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Fri, 13 Mar 2026 13:04:09 -0500 Subject: [PATCH 2/5] improvements --- README.md | 1 + apps/web/app/(main)/docs/api/yaml/page.mdx | 232 +++++++++++++++++++++ apps/web/app/api/docs-chat/route.ts | 4 +- apps/web/app/api/generate/route.ts | 17 +- apps/web/components/playground.tsx | 195 ++++++++--------- apps/web/lib/docs-navigation.ts | 1 + apps/web/lib/page-titles.ts | 1 + apps/web/lib/spec-patch.ts | 156 ++++++++++++++ apps/web/lib/use-playground-stream.ts | 217 +++---------------- packages/core/src/edit-modes.ts | 2 +- packages/core/src/index.ts | 6 +- packages/core/src/prompt.ts | 16 +- packages/yaml/README.md | 113 ++++++++++ packages/yaml/src/index.ts | 10 +- packages/yaml/src/transform.ts | 17 +- skills/yaml/SKILL.md | 141 +++++++++++++ 16 files changed, 796 insertions(+), 333 deletions(-) create mode 100644 apps/web/app/(main)/docs/api/yaml/page.mdx create mode 100644 apps/web/lib/spec-patch.ts create mode 100644 packages/yaml/README.md create mode 100644 skills/yaml/SKILL.md 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/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 97d69843..fd0c1bba 100644 --- a/apps/web/app/api/generate/route.ts +++ b/apps/web/app/api/generate/route.ts @@ -1,7 +1,11 @@ import { streamText } from "ai"; import { headers } from "next/headers"; import type { Spec, EditMode } from "@json-render/core"; -import { buildUserPrompt, buildEditUserPrompt } 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"; @@ -36,17 +40,6 @@ function getSystemPrompt(isYaml: boolean, editModes?: EditMode[]): string { }); } -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 - ); -} - function buildYamlUserPrompt( prompt: string, previousSpec?: Spec | null, diff --git a/apps/web/components/playground.tsx b/apps/web/components/playground.tsx index c2ed5ab2..f56f3770 100644 --- a/apps/web/components/playground.tsx +++ b/apps/web/components/playground.tsx @@ -54,6 +54,73 @@ function formatTokens(n: number): string { 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 && ( + + )} +
+ ); +} + /** * Convert a flat Spec into a nested tree structure that is easier for humans * to read. Children keys are resolved recursively into inline objects. @@ -463,58 +530,18 @@ ${jsx} autoFocus />
-
-
- {(["jsonl", "yaml"] as const).map((f) => ( - - ))} -
-
- {(["patch", "merge", "diff"] as const).map((m) => ( - - ))} -
- {versions.length > 0 && ( - - )} -
+ 0} + onClear={() => { + setVersions([]); + setSelectedVersionId(null); + clear(); + }} + /> {isStreaming ? ( - ))} -
-
- {(["patch", "merge", "diff"] as const).map((m) => ( - - ))} -
- {versions.length > 0 && ( - - )} -
+ 0} + onClear={() => { + setVersions([]); + setSelectedVersionId(null); + clear(); + }} + /> {isStreaming ? (