mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 12:28:17 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d2d5d8dd7 |
@@ -273,8 +273,8 @@ compiler.reset();`}</Code>
|
|||||||
</p>
|
</p>
|
||||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||||
|
|
||||||
const jsonl = \`{"op":"set","path":"/root","value":{}}
|
const jsonl = \`{"op":"add","path":"/root","value":{}}
|
||||||
{"op":"set","path":"/root/type","value":"Card"}\`;
|
{"op":"add","path":"/root/type","value":"Card"}\`;
|
||||||
|
|
||||||
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
||||||
|
|
||||||
@@ -285,22 +285,36 @@ const spec = compileSpecStream<MySpec>(jsonl);`}</Code>
|
|||||||
} from '@json-render/core';
|
} from '@json-render/core';
|
||||||
|
|
||||||
// Parse a single line
|
// Parse a single line
|
||||||
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
|
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||||
|
|
||||||
// Apply patch to object (mutates in place)
|
// Apply patch to object (mutates in place)
|
||||||
const obj = {};
|
const obj = {};
|
||||||
applySpecStreamPatch(obj, patch);`}</Code>
|
applySpecStreamPatch(obj, patch);`}</Code>
|
||||||
|
|
||||||
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
|
<h3 className="text-lg font-semibold mt-8 mb-4">SpecStream Types</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
Fully compliant with{" "}
|
||||||
|
<a
|
||||||
|
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
RFC 6902
|
||||||
|
</a>
|
||||||
|
:
|
||||||
|
</p>
|
||||||
<Code lang="typescript">{`interface SpecStreamLine {
|
<Code lang="typescript">{`interface SpecStreamLine {
|
||||||
op: 'set' | 'add' | 'replace' | 'remove';
|
op: 'add' | 'remove' | 'replace' | 'move' | 'copy' | 'test';
|
||||||
path: string;
|
path: string;
|
||||||
value?: unknown;
|
value?: unknown; // Required for add, replace, test
|
||||||
|
from?: string; // Required for move, copy
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SpecStreamCompiler<T> {
|
interface SpecStreamCompiler<T> {
|
||||||
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
push(chunk: string): { result: T; newPatches: SpecStreamLine[] };
|
||||||
getResult(): T;
|
getResult(): T;
|
||||||
|
getPatches(): SpecStreamLine[];
|
||||||
reset(): void;
|
reset(): void;
|
||||||
}`}</Code>
|
}`}</Code>
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export default function StreamingPage() {
|
|||||||
format where each line is a JSON patch operation that progressively
|
format where each line is a JSON patch operation that progressively
|
||||||
builds your spec:
|
builds your spec:
|
||||||
</p>
|
</p>
|
||||||
<Code lang="json">{`{"op":"set","path":"/root","value":"root"}
|
<Code lang="json">{`{"op":"add","path":"/root","value":"root"}
|
||||||
{"op":"set","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
|
{"op":"add","path":"/elements/root","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","metric-2"]}}
|
||||||
{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
|
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue"}}}
|
||||||
{"op":"set","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
|
{"op":"add","path":"/elements/metric-2","value":{"type":"Metric","props":{"label":"Users"}}}`}</Code>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
|
<h2 className="text-xl font-semibold mt-12 mb-4">useUIStream Hook</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
@@ -43,26 +43,47 @@ function App() {
|
|||||||
});
|
});
|
||||||
}`}</Code>
|
}`}</Code>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold mt-12 mb-4">Patch Operations</h2>
|
<h2 className="text-xl font-semibold mt-12 mb-4">
|
||||||
|
Patch Operations (RFC 6902)
|
||||||
|
</h2>
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
Supported operations:
|
SpecStream uses{" "}
|
||||||
|
<a
|
||||||
|
href="https://datatracker.ietf.org/doc/html/rfc6902"
|
||||||
|
className="underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
RFC 6902 JSON Patch
|
||||||
|
</a>{" "}
|
||||||
|
operations:
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-2 mb-4">
|
||||||
<li>
|
<li>
|
||||||
<code className="text-foreground">set</code> — Set the value at a path
|
<code className="text-foreground">add</code> — Add a value at a path
|
||||||
(creates if needed)
|
(creates or replaces for objects, inserts for arrays)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="text-foreground">add</code> — Add to an array at a
|
<code className="text-foreground">remove</code> — Remove the value at
|
||||||
path
|
a path
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="text-foreground">replace</code> — Replace value at a
|
<code className="text-foreground">replace</code> — Replace an existing
|
||||||
path
|
value at a path
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<code className="text-foreground">remove</code> — Remove value at a
|
<code className="text-foreground">move</code> — Move a value from one
|
||||||
path
|
path to another (requires{" "}
|
||||||
|
<code className="text-foreground">from</code>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-foreground">copy</code> — Copy a value from one
|
||||||
|
path to another (requires{" "}
|
||||||
|
<code className="text-foreground">from</code>)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-foreground">test</code> — Assert that a value at
|
||||||
|
a path equals the given value
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -170,8 +191,8 @@ async function processStream(reader: ReadableStreamDefaultReader) {
|
|||||||
</p>
|
</p>
|
||||||
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
<Code lang="typescript">{`import { compileSpecStream } from '@json-render/core';
|
||||||
|
|
||||||
const jsonl = \`{"op":"set","path":"/root","value":{"type":"Card"}}
|
const jsonl = \`{"op":"add","path":"/root","value":{"type":"Card"}}
|
||||||
{"op":"set","path":"/root/props","value":{"title":"Hello"}}\`;
|
{"op":"add","path":"/root/props","value":{"title":"Hello"}}\`;
|
||||||
|
|
||||||
const spec = compileSpecStream<MySpec>(jsonl);
|
const spec = compileSpecStream<MySpec>(jsonl);
|
||||||
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
|
// { root: { type: "Card", props: { title: "Hello" } } }`}</Code>
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ USER REQUEST: ${sanitizedPrompt}
|
|||||||
|
|
||||||
IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
|
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 add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
|
||||||
- To modify an existing element: {"op":"set","path":"/elements/existing-key","value":{...}}
|
- To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
|
||||||
- To update the root: {"op":"set","path":"/root","value":"new-root-key"}
|
- 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
|
- 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.`;
|
DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SIMULATION_STAGES: SimulationStage[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stream: '{"op":"set","path":"/root","value":"card"}',
|
stream: '{"op":"add","path":"/root","value":"card"}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tree: {
|
tree: {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -116,14 +116,16 @@ while (streaming) {
|
|||||||
const finalSpec = compiler.getResult();
|
const finalSpec = compiler.getResult();
|
||||||
```
|
```
|
||||||
|
|
||||||
SpecStream format (each line is a JSON patch):
|
SpecStream format uses [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) operations (each line is a patch):
|
||||||
|
|
||||||
```jsonl
|
```jsonl
|
||||||
{"op":"set","path":"/root/type","value":"Card"}
|
{"op":"add","path":"/root/type","value":"Card"}
|
||||||
{"op":"set","path":"/root/props","value":{"title":"Hello"}}
|
{"op":"add","path":"/root/props","value":{"title":"Hello"}}
|
||||||
{"op":"set","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
|
{"op":"add","path":"/root/children/0","value":{"type":"Button","props":{"label":"Click"}}}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
All six RFC 6902 operations are supported: `add`, `remove`, `replace`, `move`, `copy`, `test`.
|
||||||
|
|
||||||
### Low-Level Utilities
|
### Low-Level Utilities
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
@@ -134,8 +136,8 @@ import {
|
|||||||
} from "@json-render/core";
|
} from "@json-render/core";
|
||||||
|
|
||||||
// Parse a single line
|
// Parse a single line
|
||||||
const patch = parseSpecStreamLine('{"op":"set","path":"/root","value":{}}');
|
const patch = parseSpecStreamLine('{"op":"add","path":"/root","value":{}}');
|
||||||
// { op: "set", path: "/root", value: {} }
|
// { op: "add", path: "/root", value: {} }
|
||||||
|
|
||||||
// Apply a patch to an object
|
// Apply a patch to an object
|
||||||
const obj = {};
|
const obj = {};
|
||||||
|
|||||||
@@ -493,8 +493,8 @@ export function generateSystemPrompt<
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Output format
|
// Output format
|
||||||
lines.push("OUTPUT FORMAT (JSONL):");
|
lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
|
||||||
lines.push('{"op":"set","path":"/root","value":"element-key"}');
|
lines.push('{"op":"add","path":"/root","value":"element-key"}');
|
||||||
lines.push(
|
lines.push(
|
||||||
'{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
|
'{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}',
|
||||||
);
|
);
|
||||||
@@ -504,8 +504,8 @@ export function generateSystemPrompt<
|
|||||||
// Rules
|
// Rules
|
||||||
lines.push("RULES:");
|
lines.push("RULES:");
|
||||||
const baseRules = [
|
const baseRules = [
|
||||||
"First line sets /root to root element key",
|
'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
|
||||||
"Add elements with /elements/{key}",
|
'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
|
||||||
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
|
"Remove elements with op:remove - also update the parent's children array to exclude the removed key",
|
||||||
"Children array contains string keys, not objects",
|
"Children array contains string keys, not objects",
|
||||||
"Parent first, then children",
|
"Parent first, then children",
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ export {
|
|||||||
resolveDynamicValue,
|
resolveDynamicValue,
|
||||||
getByPath,
|
getByPath,
|
||||||
setByPath,
|
setByPath,
|
||||||
|
addByPath,
|
||||||
|
removeByPath,
|
||||||
findFormValue,
|
findFormValue,
|
||||||
// SpecStream - streaming format for building specs
|
// SpecStream - streaming format for building specs (RFC 6902)
|
||||||
parseSpecStreamLine,
|
parseSpecStreamLine,
|
||||||
applySpecStreamPatch,
|
applySpecStreamPatch,
|
||||||
compileSpecStream,
|
compileSpecStream,
|
||||||
|
|||||||
@@ -545,10 +545,10 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("Example output (each line is a separate JSON object):");
|
lines.push("Example output (each line is a separate JSON object):");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`{"op":"set","path":"/root","value":"card-1"}
|
lines.push(`{"op":"add","path":"/root","value":"card-1"}
|
||||||
{"op":"set","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
|
{"op":"add","path":"/elements/card-1","value":{"type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"]}}
|
||||||
{"op":"set","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
|
{"op":"add","path":"/elements/metric-1","value":{"type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[]}}
|
||||||
{"op":"set","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
|
{"op":"add","path":"/elements/chart-1","value":{"type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[]}}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// Components section
|
// Components section
|
||||||
@@ -591,8 +591,8 @@ function generatePrompt<TDef extends SchemaDefinition, TCatalog>(
|
|||||||
lines.push("RULES:");
|
lines.push("RULES:");
|
||||||
const baseRules = [
|
const baseRules = [
|
||||||
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
||||||
'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
|
'First line sets root: {"op":"add","path":"/root","value":"<root-key>"}',
|
||||||
'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
|
'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
|
||||||
"ONLY use components listed above",
|
"ONLY use components listed above",
|
||||||
"Each element value needs: type, props, children (array of child keys)",
|
"Each element value needs: type, props, children (array of child keys)",
|
||||||
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
|
"Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')",
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { resolveDynamicValue, getByPath, setByPath } from "./types";
|
import {
|
||||||
|
resolveDynamicValue,
|
||||||
|
getByPath,
|
||||||
|
setByPath,
|
||||||
|
addByPath,
|
||||||
|
removeByPath,
|
||||||
|
applySpecStreamPatch,
|
||||||
|
compileSpecStream,
|
||||||
|
createSpecStreamCompiler,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
describe("getByPath", () => {
|
describe("getByPath", () => {
|
||||||
it("gets nested values with JSON pointer paths", () => {
|
it("gets nested values with JSON pointer paths", () => {
|
||||||
@@ -123,3 +132,428 @@ describe("resolveDynamicValue", () => {
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// JSON Pointer (RFC 6901) escaping
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("JSON Pointer escaping (RFC 6901)", () => {
|
||||||
|
it("getByPath unescapes ~1 to /", () => {
|
||||||
|
const data = { "a/b": { c: 42 } };
|
||||||
|
expect(getByPath(data, "/a~1b/c")).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getByPath unescapes ~0 to ~", () => {
|
||||||
|
const data = { "a~b": 99 };
|
||||||
|
expect(getByPath(data, "/a~0b")).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getByPath handles combined ~0 and ~1 escaping", () => {
|
||||||
|
const data = { "a~/b": "found" };
|
||||||
|
expect(getByPath(data, "/a~0~1b")).toBe("found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setByPath unescapes ~1 to / in keys", () => {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
setByPath(data, "/a~1b", "value");
|
||||||
|
expect(data["a/b"]).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setByPath unescapes ~0 to ~ in keys", () => {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
setByPath(data, "/a~0b", "value");
|
||||||
|
expect(data["a~b"]).toBe("value");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getByPath reads array elements properly", () => {
|
||||||
|
const data = { items: [10, 20, 30] };
|
||||||
|
expect(getByPath(data, "/items/0")).toBe(10);
|
||||||
|
expect(getByPath(data, "/items/2")).toBe(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// addByPath (RFC 6902 "add" semantics)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("addByPath", () => {
|
||||||
|
it("adds a property to an object", () => {
|
||||||
|
const data: Record<string, unknown> = { a: 1 };
|
||||||
|
addByPath(data, "/b", 2);
|
||||||
|
expect(data.b).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces an existing object property (add semantics)", () => {
|
||||||
|
const data: Record<string, unknown> = { a: 1 };
|
||||||
|
addByPath(data, "/a", 99);
|
||||||
|
expect(data.a).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts into an array at a specific index", () => {
|
||||||
|
const data: Record<string, unknown> = { arr: [1, 2, 3] };
|
||||||
|
addByPath(data, "/arr/1", 99);
|
||||||
|
expect(data.arr).toEqual([1, 99, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to an array with - (end-of-array)", () => {
|
||||||
|
const data: Record<string, unknown> = { arr: [1, 2] };
|
||||||
|
addByPath(data, "/arr/-", 3);
|
||||||
|
expect(data.arr).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts at index 0 of an array", () => {
|
||||||
|
const data: Record<string, unknown> = { arr: ["b", "c"] };
|
||||||
|
addByPath(data, "/arr/0", "a");
|
||||||
|
expect(data.arr).toEqual(["a", "b", "c"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates intermediate objects", () => {
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
addByPath(data, "/x/y/z", "deep");
|
||||||
|
expect(
|
||||||
|
((data.x as Record<string, unknown>).y as Record<string, unknown>).z,
|
||||||
|
).toBe("deep");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// removeByPath (RFC 6902 "remove" semantics)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("removeByPath", () => {
|
||||||
|
it("deletes an object property", () => {
|
||||||
|
const data: Record<string, unknown> = { a: 1, b: 2 };
|
||||||
|
removeByPath(data, "/a");
|
||||||
|
expect(data).toEqual({ b: 2 });
|
||||||
|
expect("a" in data).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an element from an array by index", () => {
|
||||||
|
const data: Record<string, unknown> = { arr: [1, 2, 3] };
|
||||||
|
removeByPath(data, "/arr/1");
|
||||||
|
expect(data.arr).toEqual([1, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes nested properties", () => {
|
||||||
|
const data: Record<string, unknown> = { user: { name: "John", age: 30 } };
|
||||||
|
removeByPath(data, "/user/age");
|
||||||
|
expect(data.user).toEqual({ name: "John" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for non-existent paths", () => {
|
||||||
|
const data: Record<string, unknown> = { a: 1 };
|
||||||
|
removeByPath(data, "/b/c/d");
|
||||||
|
expect(data).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// applySpecStreamPatch - RFC 6902 operations
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("applySpecStreamPatch", () => {
|
||||||
|
describe("add operation", () => {
|
||||||
|
it("adds a new object property", () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
applySpecStreamPatch(obj, { op: "add", path: "/name", value: "Alice" });
|
||||||
|
expect(obj.name).toBe("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds nested properties creating intermediates", () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "add",
|
||||||
|
path: "/user/name",
|
||||||
|
value: "Bob",
|
||||||
|
});
|
||||||
|
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts into array at index", () => {
|
||||||
|
const obj: Record<string, unknown> = { items: [1, 3] };
|
||||||
|
applySpecStreamPatch(obj, { op: "add", path: "/items/1", value: 2 });
|
||||||
|
expect(obj.items).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends to array with -", () => {
|
||||||
|
const obj: Record<string, unknown> = { items: [1, 2] };
|
||||||
|
applySpecStreamPatch(obj, { op: "add", path: "/items/-", value: 3 });
|
||||||
|
expect(obj.items).toEqual([1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove operation", () => {
|
||||||
|
it("removes an object property", () => {
|
||||||
|
const obj: Record<string, unknown> = { name: "Alice", age: 30 };
|
||||||
|
applySpecStreamPatch(obj, { op: "remove", path: "/age" });
|
||||||
|
expect(obj).toEqual({ name: "Alice" });
|
||||||
|
expect("age" in obj).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes from array by index", () => {
|
||||||
|
const obj: Record<string, unknown> = { items: ["a", "b", "c"] };
|
||||||
|
applySpecStreamPatch(obj, { op: "remove", path: "/items/1" });
|
||||||
|
expect(obj.items).toEqual(["a", "c"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replace operation", () => {
|
||||||
|
it("replaces an existing value", () => {
|
||||||
|
const obj: Record<string, unknown> = { name: "Alice" };
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "replace",
|
||||||
|
path: "/name",
|
||||||
|
value: "Bob",
|
||||||
|
});
|
||||||
|
expect(obj.name).toBe("Bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces nested values", () => {
|
||||||
|
const obj: Record<string, unknown> = { user: { name: "Alice" } };
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "replace",
|
||||||
|
path: "/user/name",
|
||||||
|
value: "Bob",
|
||||||
|
});
|
||||||
|
expect((obj.user as Record<string, unknown>).name).toBe("Bob");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("move operation", () => {
|
||||||
|
it("moves a value from one path to another", () => {
|
||||||
|
const obj: Record<string, unknown> = { a: 1, b: 2 };
|
||||||
|
applySpecStreamPatch(obj, { op: "move", path: "/c", from: "/a" });
|
||||||
|
expect(obj).toEqual({ b: 2, c: 1 });
|
||||||
|
expect("a" in obj).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves nested values", () => {
|
||||||
|
const obj: Record<string, unknown> = {
|
||||||
|
source: { val: 42 },
|
||||||
|
target: {},
|
||||||
|
};
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "move",
|
||||||
|
path: "/target/val",
|
||||||
|
from: "/source/val",
|
||||||
|
});
|
||||||
|
expect((obj.target as Record<string, unknown>).val).toBe(42);
|
||||||
|
expect("val" in (obj.source as Record<string, unknown>)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op if from is missing", () => {
|
||||||
|
const obj: Record<string, unknown> = { a: 1 };
|
||||||
|
applySpecStreamPatch(obj, { op: "move", path: "/b" });
|
||||||
|
expect(obj).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("copy operation", () => {
|
||||||
|
it("copies a value from one path to another", () => {
|
||||||
|
const obj: Record<string, unknown> = { a: 1 };
|
||||||
|
applySpecStreamPatch(obj, { op: "copy", path: "/b", from: "/a" });
|
||||||
|
expect(obj).toEqual({ a: 1, b: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies complex values", () => {
|
||||||
|
const obj: Record<string, unknown> = {
|
||||||
|
source: { nested: { value: 42 } },
|
||||||
|
};
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "copy",
|
||||||
|
path: "/dest",
|
||||||
|
from: "/source/nested",
|
||||||
|
});
|
||||||
|
expect(obj.dest).toEqual({ value: 42 });
|
||||||
|
// Source should still exist
|
||||||
|
expect((obj.source as Record<string, unknown>).nested).toEqual({
|
||||||
|
value: 42,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op if from is missing", () => {
|
||||||
|
const obj: Record<string, unknown> = { a: 1 };
|
||||||
|
applySpecStreamPatch(obj, { op: "copy", path: "/b" });
|
||||||
|
expect(obj).toEqual({ a: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("test operation", () => {
|
||||||
|
it("succeeds when values match", () => {
|
||||||
|
const obj: Record<string, unknown> = { name: "Alice" };
|
||||||
|
expect(() =>
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "test",
|
||||||
|
path: "/name",
|
||||||
|
value: "Alice",
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds for matching objects", () => {
|
||||||
|
const obj: Record<string, unknown> = { user: { name: "Alice", age: 30 } };
|
||||||
|
expect(() =>
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "test",
|
||||||
|
path: "/user",
|
||||||
|
value: { name: "Alice", age: 30 },
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("succeeds for matching arrays", () => {
|
||||||
|
const obj: Record<string, unknown> = { items: [1, 2, 3] };
|
||||||
|
expect(() =>
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "test",
|
||||||
|
path: "/items",
|
||||||
|
value: [1, 2, 3],
|
||||||
|
}),
|
||||||
|
).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when values do not match", () => {
|
||||||
|
const obj: Record<string, unknown> = { name: "Alice" };
|
||||||
|
expect(() =>
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "test",
|
||||||
|
path: "/name",
|
||||||
|
value: "Bob",
|
||||||
|
}),
|
||||||
|
).toThrow('Test operation failed: value at "/name" does not match');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when path does not exist", () => {
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
expect(() =>
|
||||||
|
applySpecStreamPatch(obj, {
|
||||||
|
op: "test",
|
||||||
|
path: "/missing",
|
||||||
|
value: "anything",
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// compileSpecStream
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("compileSpecStream", () => {
|
||||||
|
it("compiles a series of add patches", () => {
|
||||||
|
const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||||
|
{"op":"add","path":"/age","value":30}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ name: "Alice", age: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles remove operations", () => {
|
||||||
|
const stream = `{"op":"add","path":"/a","value":1}
|
||||||
|
{"op":"add","path":"/b","value":2}
|
||||||
|
{"op":"remove","path":"/a"}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ b: 2 });
|
||||||
|
expect("a" in result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles replace operations", () => {
|
||||||
|
const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||||
|
{"op":"replace","path":"/name","value":"Bob"}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ name: "Bob" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles move operations", () => {
|
||||||
|
const stream = `{"op":"add","path":"/old","value":"data"}
|
||||||
|
{"op":"move","path":"/new","from":"/old"}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ new: "data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles copy operations", () => {
|
||||||
|
const stream = `{"op":"add","path":"/original","value":"data"}
|
||||||
|
{"op":"copy","path":"/duplicate","from":"/original"}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ original: "data", duplicate: "data" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips empty lines", () => {
|
||||||
|
const stream = `{"op":"add","path":"/a","value":1}
|
||||||
|
|
||||||
|
{"op":"add","path":"/b","value":2}`;
|
||||||
|
const result = compileSpecStream(stream);
|
||||||
|
expect(result).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses initial value", () => {
|
||||||
|
const stream = `{"op":"add","path":"/b","value":2}`;
|
||||||
|
const result = compileSpecStream(stream, { a: 1 });
|
||||||
|
expect(result).toEqual({ a: 1, b: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// createSpecStreamCompiler
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
describe("createSpecStreamCompiler", () => {
|
||||||
|
it("processes chunks incrementally", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
const { result, newPatches } = compiler.push(
|
||||||
|
'{"op":"add","path":"/name","value":"Alice"}\n',
|
||||||
|
);
|
||||||
|
expect(newPatches).toHaveLength(1);
|
||||||
|
expect(result).toEqual({ name: "Alice" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles partial lines across chunks", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
|
||||||
|
// First chunk is incomplete
|
||||||
|
const r1 = compiler.push('{"op":"add","path":"/na');
|
||||||
|
expect(r1.newPatches).toHaveLength(0);
|
||||||
|
|
||||||
|
// Complete the line
|
||||||
|
const r2 = compiler.push('me","value":"Alice"}\n');
|
||||||
|
expect(r2.newPatches).toHaveLength(1);
|
||||||
|
expect(r2.result).toEqual({ name: "Alice" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("processes remaining buffer on getResult", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
compiler.push('{"op":"add","path":"/name","value":"Alice"}');
|
||||||
|
// No newline, so not processed yet
|
||||||
|
const result = compiler.getResult();
|
||||||
|
expect(result).toEqual({ name: "Alice" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets to clean state", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
compiler.push('{"op":"add","path":"/name","value":"Alice"}\n');
|
||||||
|
compiler.reset();
|
||||||
|
const result = compiler.getResult();
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks all applied patches", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
compiler.push('{"op":"add","path":"/a","value":1}\n');
|
||||||
|
compiler.push('{"op":"add","path":"/b","value":2}\n');
|
||||||
|
const patches = compiler.getPatches();
|
||||||
|
expect(patches).toHaveLength(2);
|
||||||
|
expect(patches[0]!.op).toBe("add");
|
||||||
|
expect(patches[1]!.op).toBe("add");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles all RFC 6902 operations", () => {
|
||||||
|
const compiler = createSpecStreamCompiler();
|
||||||
|
compiler.push('{"op":"add","path":"/x","value":1}\n');
|
||||||
|
compiler.push('{"op":"add","path":"/y","value":2}\n');
|
||||||
|
compiler.push('{"op":"move","path":"/z","from":"/x"}\n');
|
||||||
|
compiler.push('{"op":"copy","path":"/w","from":"/y"}\n');
|
||||||
|
compiler.push('{"op":"remove","path":"/y"}\n');
|
||||||
|
const result = compiler.getResult();
|
||||||
|
expect(result).toEqual({ z: 1, w: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+192
-22
@@ -136,17 +136,20 @@ export type ComponentSchema = z.ZodType<Record<string, unknown>>;
|
|||||||
export type ValidationMode = "strict" | "warn" | "ignore";
|
export type ValidationMode = "strict" | "warn" | "ignore";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON patch operation types
|
* JSON patch operation types (RFC 6902)
|
||||||
*/
|
*/
|
||||||
export type PatchOp = "add" | "remove" | "replace" | "set";
|
export type PatchOp = "add" | "remove" | "replace" | "move" | "copy" | "test";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* JSON patch operation
|
* JSON patch operation (RFC 6902)
|
||||||
*/
|
*/
|
||||||
export interface JsonPatch {
|
export interface JsonPatch {
|
||||||
op: PatchOp;
|
op: PatchOp;
|
||||||
path: string;
|
path: string;
|
||||||
|
/** Required for add, replace, test */
|
||||||
value?: unknown;
|
value?: unknown;
|
||||||
|
/** Required for move, copy (source location) */
|
||||||
|
from?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,16 +171,30 @@ export function resolveDynamicValue<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a value from an object by JSON Pointer path
|
* Unescape a JSON Pointer token per RFC 6901 Section 4.
|
||||||
|
* ~1 is decoded to / and ~0 is decoded to ~ (order matters).
|
||||||
|
*/
|
||||||
|
function unescapeJsonPointer(token: string): string {
|
||||||
|
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a JSON Pointer path into unescaped segments.
|
||||||
|
*/
|
||||||
|
function parseJsonPointer(path: string): string[] {
|
||||||
|
const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
|
||||||
|
return raw.map(unescapeJsonPointer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value from an object by JSON Pointer path (RFC 6901)
|
||||||
*/
|
*/
|
||||||
export function getByPath(obj: unknown, path: string): unknown {
|
export function getByPath(obj: unknown, path: string): unknown {
|
||||||
if (!path || path === "/") {
|
if (!path || path === "/") {
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segments = path.startsWith("/")
|
const segments = parseJsonPointer(path);
|
||||||
? path.slice(1).split("/")
|
|
||||||
: path.split("/");
|
|
||||||
|
|
||||||
let current: unknown = obj;
|
let current: unknown = obj;
|
||||||
|
|
||||||
@@ -186,7 +203,10 @@ export function getByPath(obj: unknown, path: string): unknown {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof current === "object") {
|
if (Array.isArray(current)) {
|
||||||
|
const index = parseInt(segment, 10);
|
||||||
|
current = current[index];
|
||||||
|
} else if (typeof current === "object") {
|
||||||
current = (current as Record<string, unknown>)[segment];
|
current = (current as Record<string, unknown>)[segment];
|
||||||
} else {
|
} else {
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -204,7 +224,7 @@ function isNumericIndex(str: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a value in an object by JSON Pointer path.
|
* Set a value in an object by JSON Pointer path (RFC 6901).
|
||||||
* Automatically creates arrays when the path segment is a numeric index.
|
* Automatically creates arrays when the path segment is a numeric index.
|
||||||
*/
|
*/
|
||||||
export function setByPath(
|
export function setByPath(
|
||||||
@@ -212,9 +232,7 @@ export function setByPath(
|
|||||||
path: string,
|
path: string,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): void {
|
): void {
|
||||||
const segments = path.startsWith("/")
|
const segments = parseJsonPointer(path);
|
||||||
? path.slice(1).split("/")
|
|
||||||
: path.split("/");
|
|
||||||
|
|
||||||
if (segments.length === 0) return;
|
if (segments.length === 0) return;
|
||||||
|
|
||||||
@@ -224,7 +242,8 @@ export function setByPath(
|
|||||||
const segment = segments[i]!;
|
const segment = segments[i]!;
|
||||||
const nextSegment = segments[i + 1];
|
const nextSegment = segments[i + 1];
|
||||||
const nextIsNumeric =
|
const nextIsNumeric =
|
||||||
nextSegment !== undefined && isNumericIndex(nextSegment);
|
nextSegment !== undefined &&
|
||||||
|
(isNumericIndex(nextSegment) || nextSegment === "-");
|
||||||
|
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
const index = parseInt(segment, 10);
|
const index = parseInt(segment, 10);
|
||||||
@@ -242,13 +261,131 @@ export function setByPath(
|
|||||||
|
|
||||||
const lastSegment = segments[segments.length - 1]!;
|
const lastSegment = segments[segments.length - 1]!;
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
const index = parseInt(lastSegment, 10);
|
if (lastSegment === "-") {
|
||||||
current[index] = value;
|
current.push(value);
|
||||||
|
} else {
|
||||||
|
const index = parseInt(lastSegment, 10);
|
||||||
|
current[index] = value;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
current[lastSegment] = value;
|
current[lastSegment] = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a value per RFC 6902 "add" semantics.
|
||||||
|
* For objects: create-or-replace the member.
|
||||||
|
* For arrays: insert before the given index, or append if "-".
|
||||||
|
*/
|
||||||
|
export function addByPath(
|
||||||
|
obj: Record<string, unknown>,
|
||||||
|
path: string,
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
const segments = parseJsonPointer(path);
|
||||||
|
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
|
||||||
|
let current: Record<string, unknown> | unknown[] = obj;
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const segment = segments[i]!;
|
||||||
|
const nextSegment = segments[i + 1];
|
||||||
|
const nextIsNumeric =
|
||||||
|
nextSegment !== undefined &&
|
||||||
|
(isNumericIndex(nextSegment) || nextSegment === "-");
|
||||||
|
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const index = parseInt(segment, 10);
|
||||||
|
if (current[index] === undefined || typeof current[index] !== "object") {
|
||||||
|
current[index] = nextIsNumeric ? [] : {};
|
||||||
|
}
|
||||||
|
current = current[index] as Record<string, unknown> | unknown[];
|
||||||
|
} else {
|
||||||
|
if (!(segment in current) || typeof current[segment] !== "object") {
|
||||||
|
current[segment] = nextIsNumeric ? [] : {};
|
||||||
|
}
|
||||||
|
current = current[segment] as Record<string, unknown> | unknown[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = segments[segments.length - 1]!;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
if (lastSegment === "-") {
|
||||||
|
current.push(value);
|
||||||
|
} else {
|
||||||
|
const index = parseInt(lastSegment, 10);
|
||||||
|
current.splice(index, 0, value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current[lastSegment] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value per RFC 6902 "remove" semantics.
|
||||||
|
* For objects: delete the property.
|
||||||
|
* For arrays: splice out the element at the given index.
|
||||||
|
*/
|
||||||
|
export function removeByPath(obj: Record<string, unknown>, path: string): void {
|
||||||
|
const segments = parseJsonPointer(path);
|
||||||
|
|
||||||
|
if (segments.length === 0) return;
|
||||||
|
|
||||||
|
let current: Record<string, unknown> | unknown[] = obj;
|
||||||
|
|
||||||
|
for (let i = 0; i < segments.length - 1; i++) {
|
||||||
|
const segment = segments[i]!;
|
||||||
|
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const index = parseInt(segment, 10);
|
||||||
|
if (current[index] === undefined || typeof current[index] !== "object") {
|
||||||
|
return; // path does not exist
|
||||||
|
}
|
||||||
|
current = current[index] as Record<string, unknown> | unknown[];
|
||||||
|
} else {
|
||||||
|
if (!(segment in current) || typeof current[segment] !== "object") {
|
||||||
|
return; // path does not exist
|
||||||
|
}
|
||||||
|
current = current[segment] as Record<string, unknown> | unknown[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastSegment = segments[segments.length - 1]!;
|
||||||
|
if (Array.isArray(current)) {
|
||||||
|
const index = parseInt(lastSegment, 10);
|
||||||
|
if (index >= 0 && index < current.length) {
|
||||||
|
current.splice(index, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delete current[lastSegment];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep equality check for RFC 6902 "test" operation.
|
||||||
|
*/
|
||||||
|
function deepEqual(a: unknown, b: unknown): boolean {
|
||||||
|
if (a === b) return true;
|
||||||
|
if (a === null || b === null) return false;
|
||||||
|
if (typeof a !== typeof b) return false;
|
||||||
|
if (typeof a !== "object") return false;
|
||||||
|
|
||||||
|
if (Array.isArray(a)) {
|
||||||
|
if (!Array.isArray(b)) return false;
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((item, i) => deepEqual(item, b[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const aObj = a as Record<string, unknown>;
|
||||||
|
const bObj = b as Record<string, unknown>;
|
||||||
|
const aKeys = Object.keys(aObj);
|
||||||
|
const bKeys = Object.keys(bObj);
|
||||||
|
|
||||||
|
if (aKeys.length !== bKeys.length) return false;
|
||||||
|
return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a form value from params and/or data.
|
* Find a form value from params and/or data.
|
||||||
* Useful in action handlers to locate form input values regardless of path format.
|
* Useful in action handlers to locate form input values regardless of path format.
|
||||||
@@ -345,17 +482,50 @@ export function parseSpecStreamLine(line: string): SpecStreamLine | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a single SpecStream patch to an object.
|
* Apply a single RFC 6902 JSON Patch operation to an object.
|
||||||
* Mutates the object in place.
|
* Mutates the object in place.
|
||||||
|
*
|
||||||
|
* Supports all six RFC 6902 operations: add, remove, replace, move, copy, test.
|
||||||
|
*
|
||||||
|
* @throws {Error} If a "test" operation fails (value mismatch).
|
||||||
*/
|
*/
|
||||||
export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
||||||
obj: T,
|
obj: T,
|
||||||
patch: SpecStreamLine,
|
patch: SpecStreamLine,
|
||||||
): T {
|
): T {
|
||||||
if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
|
switch (patch.op) {
|
||||||
setByPath(obj, patch.path, patch.value);
|
case "add":
|
||||||
} else if (patch.op === "remove") {
|
addByPath(obj, patch.path, patch.value);
|
||||||
setByPath(obj, patch.path, undefined);
|
break;
|
||||||
|
case "replace":
|
||||||
|
// RFC 6902: target must exist. For streaming tolerance we set regardless.
|
||||||
|
setByPath(obj, patch.path, patch.value);
|
||||||
|
break;
|
||||||
|
case "remove":
|
||||||
|
removeByPath(obj, patch.path);
|
||||||
|
break;
|
||||||
|
case "move": {
|
||||||
|
if (!patch.from) break;
|
||||||
|
const moveValue = getByPath(obj, patch.from);
|
||||||
|
removeByPath(obj, patch.from);
|
||||||
|
addByPath(obj, patch.path, moveValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "copy": {
|
||||||
|
if (!patch.from) break;
|
||||||
|
const copyValue = getByPath(obj, patch.from);
|
||||||
|
addByPath(obj, patch.path, copyValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "test": {
|
||||||
|
const actual = getByPath(obj, patch.path);
|
||||||
|
if (!deepEqual(actual, patch.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Test operation failed: value at "${patch.path}" does not match`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
@@ -365,8 +535,8 @@ export function applySpecStreamPatch<T extends Record<string, unknown>>(
|
|||||||
* Each line should be a patch operation.
|
* Each line should be a patch operation.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* const stream = `{"op":"set","path":"/name","value":"Alice"}
|
* const stream = `{"op":"add","path":"/name","value":"Alice"}
|
||||||
* {"op":"set","path":"/age","value":30}`;
|
* {"op":"add","path":"/age","value":30}`;
|
||||||
* const result = compileSpecStream(stream);
|
* const result = compileSpecStream(stream);
|
||||||
* // { name: "Alice", age: 30 }
|
* // { name: "Alice", age: 30 }
|
||||||
*/
|
*/
|
||||||
|
|||||||
+94
-41
@@ -7,7 +7,12 @@ import type {
|
|||||||
FlatElement,
|
FlatElement,
|
||||||
JsonPatch,
|
JsonPatch,
|
||||||
} from "@json-render/core";
|
} from "@json-render/core";
|
||||||
import { setByPath } from "@json-render/core";
|
import {
|
||||||
|
setByPath,
|
||||||
|
getByPath,
|
||||||
|
addByPath,
|
||||||
|
removeByPath,
|
||||||
|
} from "@json-render/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a single JSON patch line
|
* Parse a single JSON patch line
|
||||||
@@ -25,56 +30,104 @@ function parsePatchLine(line: string): JsonPatch | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply a JSON patch to the current spec
|
* Set a value at a spec path (for add/replace operations).
|
||||||
|
*/
|
||||||
|
function setSpecValue(newSpec: Spec, path: string, value: unknown): void {
|
||||||
|
if (path === "/root") {
|
||||||
|
newSpec.root = value as string;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.startsWith("/elements/")) {
|
||||||
|
const pathParts = path.slice("/elements/".length).split("/");
|
||||||
|
const elementKey = pathParts[0];
|
||||||
|
if (!elementKey) return;
|
||||||
|
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
newSpec.elements[elementKey] = value as UIElement;
|
||||||
|
} else {
|
||||||
|
const element = newSpec.elements[elementKey];
|
||||||
|
if (element) {
|
||||||
|
const propPath = "/" + pathParts.slice(1).join("/");
|
||||||
|
const newElement = { ...element };
|
||||||
|
setByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
propPath,
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a value at a spec path.
|
||||||
|
*/
|
||||||
|
function removeSpecValue(newSpec: Spec, path: string): void {
|
||||||
|
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 propPath = "/" + pathParts.slice(1).join("/");
|
||||||
|
const newElement = { ...element };
|
||||||
|
removeByPath(
|
||||||
|
newElement as unknown as Record<string, unknown>,
|
||||||
|
propPath,
|
||||||
|
);
|
||||||
|
newSpec.elements[elementKey] = newElement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a value at a spec path.
|
||||||
|
*/
|
||||||
|
function getSpecValue(spec: Spec, path: string): unknown {
|
||||||
|
if (path === "/root") return spec.root;
|
||||||
|
return getByPath(spec as unknown as Record<string, unknown>, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an RFC 6902 JSON patch to the current spec.
|
||||||
|
* Supports add, remove, replace, move, copy, and test operations.
|
||||||
*/
|
*/
|
||||||
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
function applyPatch(spec: Spec, patch: JsonPatch): Spec {
|
||||||
const newSpec = { ...spec, elements: { ...spec.elements } };
|
const newSpec = { ...spec, elements: { ...spec.elements } };
|
||||||
|
|
||||||
switch (patch.op) {
|
switch (patch.op) {
|
||||||
case "set":
|
|
||||||
case "add":
|
case "add":
|
||||||
case "replace": {
|
case "replace": {
|
||||||
// Handle root path
|
setSpecValue(newSpec, patch.path, patch.value);
|
||||||
if (patch.path === "/root") {
|
|
||||||
newSpec.root = patch.value as string;
|
|
||||||
return newSpec;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle elements paths
|
|
||||||
if (patch.path.startsWith("/elements/")) {
|
|
||||||
const pathParts = patch.path.slice("/elements/".length).split("/");
|
|
||||||
const elementKey = pathParts[0];
|
|
||||||
|
|
||||||
if (!elementKey) return newSpec;
|
|
||||||
|
|
||||||
if (pathParts.length === 1) {
|
|
||||||
// Setting entire element
|
|
||||||
newSpec.elements[elementKey] = patch.value as UIElement;
|
|
||||||
} else {
|
|
||||||
// Setting property of element
|
|
||||||
const element = newSpec.elements[elementKey];
|
|
||||||
if (element) {
|
|
||||||
const propPath = "/" + pathParts.slice(1).join("/");
|
|
||||||
const newElement = { ...element };
|
|
||||||
setByPath(
|
|
||||||
newElement as unknown as Record<string, unknown>,
|
|
||||||
propPath,
|
|
||||||
patch.value,
|
|
||||||
);
|
|
||||||
newSpec.elements[elementKey] = newElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "remove": {
|
case "remove": {
|
||||||
if (patch.path.startsWith("/elements/")) {
|
removeSpecValue(newSpec, patch.path);
|
||||||
const elementKey = patch.path.slice("/elements/".length).split("/")[0];
|
break;
|
||||||
if (elementKey) {
|
}
|
||||||
const { [elementKey]: _, ...rest } = newSpec.elements;
|
case "move": {
|
||||||
newSpec.elements = rest;
|
if (!patch.from) break;
|
||||||
}
|
const moveValue = getSpecValue(newSpec, patch.from);
|
||||||
}
|
removeSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, moveValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "copy": {
|
||||||
|
if (!patch.from) break;
|
||||||
|
const copyValue = getSpecValue(newSpec, patch.from);
|
||||||
|
setSpecValue(newSpec, patch.path, copyValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "test": {
|
||||||
|
// test is a no-op for rendering purposes (validation only)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,11 +25,12 @@ function remotionPromptTemplate(context: PromptContext): string {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push("Example output (each line is a separate JSON object):");
|
lines.push("Example output (each line is a separate JSON object):");
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
|
lines.push(`{"op":"add","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
|
||||||
{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
|
{"op":"add","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
|
||||||
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
|
{"op":"add","path":"/clips","value":[]}
|
||||||
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
|
{"op":"add","path":"/clips/-","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
|
||||||
{"op":"set","path":"/audio","value":{"tracks":[]}}`);
|
{"op":"add","path":"/clips/-","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
|
||||||
|
{"op":"add","path":"/audio","value":{"tracks":[]}}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
@@ -107,10 +108,10 @@ function remotionPromptTemplate(context: PromptContext): string {
|
|||||||
lines.push("RULES:");
|
lines.push("RULES:");
|
||||||
const baseRules = [
|
const baseRules = [
|
||||||
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
"Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
|
||||||
"First set /composition with {id, fps:30, width:1920, height:1080, durationInFrames}",
|
'First add /composition with {id, fps:30, width:1920, height:1080, durationInFrames}: {"op":"add","path":"/composition","value":{...}}',
|
||||||
"Then set /tracks array with video/overlay tracks",
|
'Then add /tracks array with video/overlay tracks: {"op":"add","path":"/tracks","value":[...]}',
|
||||||
"Then set each clip: /clips/0, /clips/1, etc.",
|
'Then add each clip by appending to the array: {"op":"add","path":"/clips/-","value":{...}}',
|
||||||
"Finally set /audio with {tracks:[]}",
|
'Finally add /audio with {tracks:[]}: {"op":"add","path":"/audio","value":{...}}',
|
||||||
"ONLY use components listed above",
|
"ONLY use components listed above",
|
||||||
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
|
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
|
||||||
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
|
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
|
||||||
|
|||||||
Reference in New Issue
Block a user