Compare commits

...
Author SHA1 Message Date
github-actions[bot] e9ea9c782b chore: version packages (#80)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-07 12:31:01 -06:00
Chris Tate dd17549ee8 next release (#79) 2026-02-07 12:17:32 -06:00
Chris Tate 1eb6212dd7 fix json patch (#78) 2026-02-07 12:09:53 -06:00
21 changed files with 847 additions and 119 deletions
+19 -5
View File
@@ -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>
+37 -16
View File
@@ -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>
+3 -2
View File
@@ -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.`;
+1 -1
View File
@@ -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
+8
View File
@@ -1,5 +1,13 @@
# @json-render/codegen # @json-render/codegen
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3 ## 0.4.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/codegen", "name": "@json-render/codegen",
"version": "0.4.3", "version": "0.4.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Utilities for generating code from json-render UI trees", "description": "Utilities for generating code from json-render UI trees",
"keywords": [ "keywords": [
+6
View File
@@ -1,5 +1,11 @@
# @json-render/core # @json-render/core
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
## 0.4.3 ## 0.4.3
### Patch Changes ### Patch Changes
+8 -6
View File
@@ -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 = {};
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/core", "name": "@json-render/core",
"version": "0.4.3", "version": "0.4.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "JSON becomes real things. Define your catalog, register your components, let AI generate.", "description": "JSON becomes real things. Define your catalog, register your components, let AI generate.",
"keywords": [ "keywords": [
+4 -4
View File
@@ -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",
+3 -1
View File
@@ -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,
+6 -6
View File
@@ -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')",
+435 -1
View File
@@ -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
View File
@@ -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 }
*/ */
+8
View File
@@ -1,5 +1,13 @@
# @json-render/react # @json-render/react
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3 ## 0.4.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/react", "name": "@json-render/react",
"version": "0.4.3", "version": "0.4.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "React renderer for @json-render/core. JSON becomes React components.", "description": "React renderer for @json-render/core. JSON becomes React components.",
"keywords": [ "keywords": [
+94 -41
View File
@@ -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;
} }
} }
+8
View File
@@ -1,5 +1,13 @@
# @json-render/remotion # @json-render/remotion
## 0.4.4
### Patch Changes
- dd17549: remove key/parentKey from flat specs, RFC 6902 compliance for SpecStream
- Updated dependencies [dd17549]
- @json-render/core@0.4.4
## 0.4.3 ## 0.4.3
### Patch Changes ### Patch Changes
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@json-render/remotion", "name": "@json-render/remotion",
"version": "0.4.3", "version": "0.4.4",
"license": "Apache-2.0", "license": "Apache-2.0",
"description": "Remotion renderer for @json-render/core. JSON becomes video compositions.", "description": "Remotion renderer for @json-render/core. JSON becomes video compositions.",
"keywords": [ "keywords": [
+10 -9
View File
@@ -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)',