mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-14 05:19:14 +08:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3af511168e | ||
|
|
2f7cd1229c | ||
|
|
d9c2a57ba6 | ||
|
|
b25f057061 | ||
|
|
1c99456ec8 | ||
|
|
d96bf29ce6 | ||
|
|
950cddf407 | ||
|
|
c26d3b08d3 |
@@ -199,6 +199,24 @@ With comparison:
|
||||
|
||||
This shows the divider for every item except the first (index 0).
|
||||
|
||||
### Filtered lists — `$item` on the repeat container
|
||||
|
||||
Putting an `$item` condition directly on the element that declares `repeat` filters which items render. This is the natural way to build kanban columns, tabbed lists, or status sections from one state array:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Stack",
|
||||
"repeat": { "statePath": "/tasks", "key": "id" },
|
||||
"visible": { "$item": "status", "eq": "todo" },
|
||||
"children": ["task-card"]
|
||||
}
|
||||
```
|
||||
|
||||
One child renders per matching item; non-matching items are skipped. When the condition is an `$and` (or array) that mixes scopes, the `$state` parts gate the container itself (a false gate hides the whole shell) while the `$item`/`$index` parts filter items. A mixed `$or` cannot be split and is applied entirely per item.
|
||||
|
||||
Filtered lists are currently implemented by the React renderer; other renderers evaluate the container condition outside the repeat scope.
|
||||
|
||||
|
||||
`$item` and `$index` conditions support the same comparison operators as `$state` (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `not`).
|
||||
|
||||
## Complex Example
|
||||
|
||||
+11
-2
@@ -230,7 +230,7 @@ Schema options:
|
||||
| Export | Purpose |
|
||||
|--------|---------|
|
||||
| `validateSpec(spec, options?)` | Validate spec structure and return issues |
|
||||
| `autoFixSpec(spec)` | Auto-fix common spec issues (returns corrected copy) |
|
||||
| `autoFixSpec(spec, options?)` | Auto-fix common spec issues; `fixDetails` classifies each fix as lossy or lossless, `{ lossy: false }` withholds pruning |
|
||||
| `formatSpecIssues(issues)` | Format validation issues as readable strings |
|
||||
|
||||
### Actions
|
||||
@@ -553,7 +553,16 @@ const { valid, issues } = validateSpec(spec);
|
||||
console.log(formatSpecIssues(issues));
|
||||
|
||||
// Auto-fix common issues (returns a corrected copy)
|
||||
const fixed = autoFixSpec(spec);
|
||||
const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
|
||||
```
|
||||
|
||||
`validateSpec` checks structure beyond the catalog schema: missing or dangling `children` references, malformed `visible` conditions (anything outside the documented forms evaluates to hidden at runtime, so it is rejected with code `invalid_visible`), `repeat` containers with no children (`repeat_without_children`), and `repeat.statePath` values that do not reference an array in the spec's own `state` (`repeat_state_mismatch`).
|
||||
|
||||
`autoFixSpec` distinguishes lossless fixes (relocating `visible`/`on`/`repeat`/`watch` out of `props`) from lossy ones (pruning `children` references to elements that were never defined). Each entry in `fixDetails` carries `{ message, lossy }`. Callers with a repair loop should apply lossless fixes immediately and prefer re-prompting over lossy fixes, passing `{ lossy: false }` to withhold pruning until retries are exhausted:
|
||||
|
||||
```typescript
|
||||
const lastAttempt = retriesUsed >= maxRetries;
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec, { lossy: lastAttempt });
|
||||
```
|
||||
|
||||
## State Watchers
|
||||
|
||||
@@ -67,6 +67,9 @@ export type { VisibilityContext } from "./visibility";
|
||||
|
||||
export {
|
||||
VisibilityConditionSchema,
|
||||
VisibilityConditionStrictSchema,
|
||||
conditionUsesItemScope,
|
||||
splitRepeatVisibility,
|
||||
evaluateVisibility,
|
||||
visibility,
|
||||
} from "./visibility";
|
||||
|
||||
@@ -147,7 +147,239 @@ describe("validateSpec", () => {
|
||||
// autoFixSpec
|
||||
// =============================================================================
|
||||
|
||||
describe("repeat validation", () => {
|
||||
it("rejects repeat without children", () => {
|
||||
const result = validateSpec({
|
||||
root: "list",
|
||||
state: { items: [{ id: "1" }] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.issues.some((i) => i.code === "repeat_without_children"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects repeat over a non-array state value", () => {
|
||||
const result = validateSpec({
|
||||
root: "list",
|
||||
state: { items: { "1": { title: "x" } } },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "repeat_state_mismatch")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects repeat over a missing state path when state is provided", () => {
|
||||
const result = validateSpec({
|
||||
root: "list",
|
||||
state: { other: [] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "repeat_state_mismatch")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a well-formed repeat and skips state checks when no state is provided", () => {
|
||||
const withState = validateSpec({
|
||||
root: "list",
|
||||
state: { items: [{ id: "1" }] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
expect(withState.valid).toBe(true);
|
||||
const runtimeState = validateSpec({
|
||||
root: "list",
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: { type: "Text", props: {}, children: [] },
|
||||
},
|
||||
});
|
||||
expect(runtimeState.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visible condition validation", () => {
|
||||
const base = (visible: unknown): Spec => ({
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { text: "hi" },
|
||||
children: [],
|
||||
visible: visible as Spec["elements"][string]["visible"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("accepts documented forms", () => {
|
||||
for (const visible of [
|
||||
true,
|
||||
false,
|
||||
{ $state: "/tab", eq: "home" },
|
||||
{ $item: "status", eq: "todo" },
|
||||
{ $index: true, lt: 3 },
|
||||
[
|
||||
{ $state: "/a", eq: 1 },
|
||||
{ $item: "b", neq: 2 },
|
||||
],
|
||||
{ $or: [{ $state: "/a", eq: 1 }, { $and: [{ $item: "b", not: true }] }] },
|
||||
]) {
|
||||
expect(validateSpec(base(visible)).valid).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects conditions mixing $state and $item (silently hidden at runtime)", () => {
|
||||
const result = validateSpec(
|
||||
base([{ $state: "/tasks", $item: "status", eq: "todo" }]),
|
||||
);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(
|
||||
result.issues.some((issue) => issue.code === "invalid_visible"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unknown condition shapes", () => {
|
||||
const result = validateSpec(base({ when: "/tasks", is: "todo" }));
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues[0]!.code).toBe("invalid_visible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoFixSpec", () => {
|
||||
it("prunes children references to undefined elements", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Card", props: {}, children: ["text", "ghost"] },
|
||||
text: { type: "Text", props: { text: "hi" }, children: [] },
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes, fixDetails } = autoFixSpec(spec);
|
||||
expect(fixed.elements.root!.children).toEqual(["text"]);
|
||||
expect(fixes).toEqual([
|
||||
'Removed reference to undefined element "ghost" from children of "root".',
|
||||
]);
|
||||
expect(fixDetails).toEqual([
|
||||
{
|
||||
message:
|
||||
'Removed reference to undefined element "ghost" from children of "root".',
|
||||
lossy: true,
|
||||
},
|
||||
]);
|
||||
expect(validateSpec(fixed).valid).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves intact children untouched", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: { type: "Card", props: {}, children: ["text"] },
|
||||
text: { type: "Text", props: { text: "hi" }, children: [] },
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixes } = autoFixSpec(spec);
|
||||
expect(fixed.elements.root!.children).toEqual(["text"]);
|
||||
expect(fixes).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not prune a repeat container down to zero children", () => {
|
||||
const spec: Spec = {
|
||||
root: "list",
|
||||
state: { items: [{ id: "1" }] },
|
||||
elements: {
|
||||
list: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/items" },
|
||||
children: ["ghost"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec);
|
||||
expect(fixed.elements.list!.children).toEqual(["ghost"]);
|
||||
expect(fixDetails).toEqual([]);
|
||||
// The real problem (missing template) stays visible to the repair loop.
|
||||
const result = validateSpec(fixed);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
|
||||
});
|
||||
|
||||
it("withholds lossy fixes when options.lossy is false", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Card",
|
||||
props: { visible: true },
|
||||
children: ["ghost"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec, { lossy: false });
|
||||
expect(fixed.elements.root!.children).toEqual(["ghost"]);
|
||||
expect(fixDetails.every((fix) => !fix.lossy)).toBe(true);
|
||||
expect(fixDetails.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("classifies field relocations as lossless", () => {
|
||||
const { fixDetails } = autoFixSpec({
|
||||
root: "root",
|
||||
elements: {
|
||||
root: {
|
||||
type: "Text",
|
||||
props: { text: "hi", visible: true },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(fixDetails).toEqual([
|
||||
{
|
||||
message: 'Moved "visible" from props to element level on "root".',
|
||||
lossy: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves visible from props to element level", () => {
|
||||
const spec: Spec = {
|
||||
root: "root",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Spec, UIElement } from "./types";
|
||||
import { getByPath } from "./types";
|
||||
import { VisibilityConditionStrictSchema } from "./visibility";
|
||||
|
||||
// =============================================================================
|
||||
// Spec Structural Validation
|
||||
@@ -24,6 +26,9 @@ export interface SpecIssue {
|
||||
| "missing_root"
|
||||
| "root_not_found"
|
||||
| "missing_child"
|
||||
| "invalid_visible"
|
||||
| "repeat_without_children"
|
||||
| "repeat_state_mismatch"
|
||||
| "visible_in_props"
|
||||
| "orphaned_element"
|
||||
| "empty_spec"
|
||||
@@ -122,6 +127,44 @@ export function validateSpec(
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Repeat containers that can never render anything. Both shapes pass
|
||||
// schema validation but produce silently empty regions at runtime.
|
||||
if (element.repeat !== undefined) {
|
||||
if (!element.children || element.children.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" has "repeat" but no children. The repeated template must be a child element: add a child that renders one item (it may read fields with {"$item": "field"}).`,
|
||||
elementKey: key,
|
||||
code: "repeat_without_children",
|
||||
});
|
||||
}
|
||||
if (spec.state !== undefined) {
|
||||
const value = getByPath(spec.state, element.repeat.statePath);
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" repeats over "${element.repeat.statePath}" but state${value === undefined ? " has no value there" : ` has a ${typeof value} there`}. Repeat statePath must reference an array in state; add sample items to state at that path.`,
|
||||
elementKey: key,
|
||||
code: "repeat_state_mismatch",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3b. Malformed visible condition. Unrecognized shapes silently evaluate
|
||||
// to hidden at runtime, so catch them here with a repairable message.
|
||||
if (
|
||||
element.visible !== undefined &&
|
||||
!VisibilityConditionStrictSchema.safeParse(element.visible).success
|
||||
) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Element "${key}" has an invalid "visible" condition: ${JSON.stringify(element.visible)}. Valid forms: true, false, {"$state":"/path","eq":value}, {"$item":"field","eq":value}, {"$index":true,"eq":n}, an array of those (AND), or {"$and":[...]} / {"$or":[...]}. Use exactly one of $state, $item, or $index per condition object.`,
|
||||
elementKey: key,
|
||||
code: "invalid_visible",
|
||||
});
|
||||
}
|
||||
|
||||
// 3b. `visible` inside props
|
||||
const props = element.props as Record<string, unknown> | undefined;
|
||||
if (props && "visible" in props && props.visible !== undefined) {
|
||||
@@ -209,11 +252,42 @@ export function validateSpec(
|
||||
*
|
||||
* Returns the fixed spec and a list of fixes applied.
|
||||
*/
|
||||
export function autoFixSpec(spec: Spec): {
|
||||
export interface SpecFix {
|
||||
message: string;
|
||||
/**
|
||||
* Lossy fixes change what renders (e.g. pruning a dangling child
|
||||
* reference); lossless fixes only relocate misplaced fields. Callers with a
|
||||
* repair loop should prefer re-prompting over accepting lossy fixes, and
|
||||
* use the lossy-fixed spec as a last resort.
|
||||
*/
|
||||
lossy: boolean;
|
||||
}
|
||||
|
||||
export interface AutoFixOptions {
|
||||
/**
|
||||
* Apply lossy fixes (content pruning). Default true. Callers with a repair
|
||||
* loop should pass false while retries remain so the model regenerates the
|
||||
* missing content, then true as a last resort.
|
||||
*/
|
||||
lossy?: boolean;
|
||||
}
|
||||
|
||||
export function autoFixSpec(
|
||||
spec: Spec,
|
||||
options: AutoFixOptions = {},
|
||||
): {
|
||||
spec: Spec;
|
||||
fixes: string[];
|
||||
/** Structured fix records; fixes is the plain-message projection. */
|
||||
fixDetails: SpecFix[];
|
||||
} {
|
||||
const fixes: string[] = [];
|
||||
const applyLossy = options.lossy !== false;
|
||||
const fixDetails: SpecFix[] = [];
|
||||
const fixes = {
|
||||
push(message: string, lossy = false) {
|
||||
fixDetails.push({ message, lossy });
|
||||
},
|
||||
};
|
||||
const fixedElements: Record<string, UIElement> = {};
|
||||
|
||||
for (const [key, element] of Object.entries(spec.elements)) {
|
||||
@@ -277,9 +351,37 @@ export function autoFixSpec(spec: Spec): {
|
||||
fixedElements[key] = fixed;
|
||||
}
|
||||
|
||||
// Drop references to elements that were never defined. The renderer skips
|
||||
// missing children at runtime, so pruning produces the same rendered output
|
||||
// while letting the spec pass validation instead of hard-failing.
|
||||
if (applyLossy)
|
||||
for (const [key, element] of Object.entries(fixedElements)) {
|
||||
if (!element.children || element.children.length === 0) continue;
|
||||
const present = element.children.filter(
|
||||
(child) => child in fixedElements,
|
||||
);
|
||||
if (present.length === element.children.length) continue;
|
||||
if (element.repeat !== undefined && present.length === 0) {
|
||||
// Pruning every child of a repeat container would only trade the
|
||||
// missing_child error for repeat_without_children; keep the dangling
|
||||
// reference so repair targets the real problem (the missing template).
|
||||
continue;
|
||||
}
|
||||
for (const child of element.children) {
|
||||
if (!(child in fixedElements)) {
|
||||
fixes.push(
|
||||
`Removed reference to undefined element "${child}" from children of "${key}".`,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
fixedElements[key] = { ...element, children: present };
|
||||
}
|
||||
|
||||
return {
|
||||
spec: { root: spec.root, elements: fixedElements, state: spec.state },
|
||||
fixes,
|
||||
fixes: fixDetails.map((fix) => fix.message),
|
||||
fixDetails,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { evaluateVisibility, visibility } from "./visibility";
|
||||
import {
|
||||
evaluateVisibility,
|
||||
splitRepeatVisibility,
|
||||
visibility,
|
||||
} from "./visibility";
|
||||
|
||||
describe("evaluateVisibility", () => {
|
||||
describe("undefined / boolean", () => {
|
||||
@@ -725,3 +729,53 @@ describe("visibility helper", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRepeatVisibility", () => {
|
||||
it("passes through pure container conditions", () => {
|
||||
const cond = { $state: "/show", eq: true };
|
||||
expect(splitRepeatVisibility(cond)).toEqual({
|
||||
container: cond,
|
||||
itemFilter: undefined,
|
||||
});
|
||||
expect(splitRepeatVisibility(undefined)).toEqual({
|
||||
container: undefined,
|
||||
itemFilter: undefined,
|
||||
});
|
||||
expect(splitRepeatVisibility(true)).toEqual({
|
||||
container: true,
|
||||
itemFilter: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes pure item conditions to the item filter", () => {
|
||||
const cond = { $item: "status", eq: "todo" };
|
||||
expect(splitRepeatVisibility(cond)).toEqual({
|
||||
container: undefined,
|
||||
itemFilter: cond,
|
||||
});
|
||||
});
|
||||
|
||||
it("partitions AND-composed mixed conditions", () => {
|
||||
const state = { $state: "/show", eq: true };
|
||||
const item = { $item: "status", eq: "todo" };
|
||||
for (const cond of [[state, item], { $and: [state, item] }]) {
|
||||
expect(splitRepeatVisibility(cond as never)).toEqual({
|
||||
container: { $and: [state] },
|
||||
itemFilter: { $and: [item] },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps mixed $or entirely as an item filter", () => {
|
||||
const cond = {
|
||||
$or: [
|
||||
{ $state: "/all", eq: true },
|
||||
{ $item: "pinned", eq: true },
|
||||
],
|
||||
};
|
||||
expect(splitRepeatVisibility(cond)).toEqual({
|
||||
container: undefined,
|
||||
itemFilter: cond,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,89 @@ export const VisibilityConditionSchema: z.ZodType<VisibilityCondition> = z.lazy(
|
||||
]),
|
||||
);
|
||||
|
||||
const StrictSingleConditionSchema = z.union([
|
||||
z.strictObject({ $state: z.string(), ...comparisonOps }),
|
||||
z.strictObject({ $item: z.string(), ...comparisonOps }),
|
||||
z.strictObject({ $index: z.literal(true), ...comparisonOps }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strict variant for spec validation: rejects unknown keys, so malformed
|
||||
* conditions (e.g. mixing $state and $item in one object) are caught at
|
||||
* validation time instead of silently evaluating to hidden at runtime.
|
||||
*/
|
||||
/**
|
||||
* True when a condition references the repeat-item scope ($item or $index)
|
||||
* anywhere in its tree. Renderers use this to apply a repeat container's own
|
||||
* visible condition as a per-item filter instead of evaluating it (and
|
||||
* failing) outside the repeat scope.
|
||||
*/
|
||||
export function conditionUsesItemScope(
|
||||
condition: VisibilityCondition | undefined,
|
||||
): boolean {
|
||||
if (condition === undefined || typeof condition === "boolean") return false;
|
||||
if (Array.isArray(condition)) return condition.some(conditionUsesItemScope);
|
||||
if (typeof condition !== "object" || condition === null) return false;
|
||||
if ("$item" in condition || "$index" in condition) return true;
|
||||
if ("$and" in condition)
|
||||
return (condition as { $and: VisibilityCondition[] }).$and.some(
|
||||
conditionUsesItemScope,
|
||||
);
|
||||
if ("$or" in condition)
|
||||
return (condition as { $or: VisibilityCondition[] }).$or.some(
|
||||
conditionUsesItemScope,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a repeat container's visible condition into a container-level gate
|
||||
* and a per-item filter. Top-level AND structures (arrays, $and) partition
|
||||
* cleanly: conjuncts that reference $item/$index filter items, the rest gate
|
||||
* the container. An $or that mixes scopes cannot be partitioned soundly and
|
||||
* is applied entirely per item (state parts still evaluate correctly there;
|
||||
* the container shell just cannot be hidden by it).
|
||||
*/
|
||||
export function splitRepeatVisibility(
|
||||
condition: VisibilityCondition | undefined,
|
||||
): {
|
||||
container: VisibilityCondition | undefined;
|
||||
itemFilter: VisibilityCondition | undefined;
|
||||
} {
|
||||
if (condition === undefined || !conditionUsesItemScope(condition)) {
|
||||
return { container: condition, itemFilter: undefined };
|
||||
}
|
||||
const partition = (parts: VisibilityCondition[]) => {
|
||||
const container = parts.filter((part) => !conditionUsesItemScope(part));
|
||||
const item = parts.filter((part) => conditionUsesItemScope(part));
|
||||
return {
|
||||
container: container.length > 0 ? { $and: container } : undefined,
|
||||
itemFilter: item.length > 0 ? { $and: item } : undefined,
|
||||
};
|
||||
};
|
||||
if (Array.isArray(condition)) return partition(condition);
|
||||
if (
|
||||
typeof condition === "object" &&
|
||||
condition !== null &&
|
||||
"$and" in condition
|
||||
) {
|
||||
return partition((condition as { $and: VisibilityCondition[] }).$and);
|
||||
}
|
||||
// Single item-scoped condition or an $or that mixes scopes.
|
||||
return { container: undefined, itemFilter: condition };
|
||||
}
|
||||
|
||||
export const VisibilityConditionStrictSchema: z.ZodType<VisibilityCondition> =
|
||||
z.lazy(() =>
|
||||
z.union([
|
||||
z.boolean(),
|
||||
StrictSingleConditionSchema,
|
||||
z.array(StrictSingleConditionSchema),
|
||||
z.strictObject({ $and: z.array(VisibilityConditionStrictSchema) }),
|
||||
z.strictObject({ $or: z.array(VisibilityConditionStrictSchema) }),
|
||||
]),
|
||||
);
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
@@ -417,10 +417,16 @@ export function useUIStream({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Post-stream: auto-fix deterministic issues (no retry needed)
|
||||
// Post-stream: auto-fix deterministic issues. Lossless fixes (field
|
||||
// relocations) apply immediately. Lossy fixes (pruned content) are
|
||||
// held back while retries remain so validation fails and the model
|
||||
// is asked to repair; they apply as a last resort once retries are
|
||||
// exhausted, trading dropped content for a renderable spec.
|
||||
// ---------------------------------------------------------------
|
||||
const { spec: fixedSpec, fixes } = autoFixSpec(currentSpec);
|
||||
if (fixes.length > 0) {
|
||||
const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
|
||||
lossy: retriesUsed >= maxRetries,
|
||||
});
|
||||
if (fixDetails.length > 0) {
|
||||
currentSpec = fixedSpec;
|
||||
setSpec({ ...currentSpec });
|
||||
}
|
||||
|
||||
@@ -523,10 +523,16 @@ export function useUIStream({
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Post-stream: auto-fix deterministic issues (no retry needed)
|
||||
// Post-stream: auto-fix deterministic issues. Lossless fixes (field
|
||||
// relocations) apply immediately. Lossy fixes (pruned content) are
|
||||
// held back while retries remain so validation fails and the model
|
||||
// is asked to repair; they apply as a last resort once retries are
|
||||
// exhausted, trading dropped content for a renderable spec.
|
||||
// ---------------------------------------------------------------
|
||||
const { spec: fixedSpec, fixes } = autoFixSpec(currentSpec);
|
||||
if (fixes.length > 0) {
|
||||
const { spec: fixedSpec, fixDetails } = autoFixSpec(currentSpec, {
|
||||
lossy: retriesUsed >= maxRetries,
|
||||
});
|
||||
if (fixDetails.length > 0) {
|
||||
currentSpec = fixedSpec;
|
||||
setSpec({ ...currentSpec });
|
||||
}
|
||||
@@ -557,6 +563,21 @@ export function useUIStream({
|
||||
// continue loop
|
||||
}
|
||||
|
||||
// If retries were exhausted and validation still fails, report error
|
||||
// instead of silently treating partial/invalid specs as complete.
|
||||
if (enableValidation && retriesUsed >= maxRetries && currentSpec.root) {
|
||||
const finalValidation = validateSpec(currentSpec);
|
||||
if (!finalValidation.valid) {
|
||||
const issueText = formatSpecIssues(finalValidation.issues);
|
||||
const validationError = new Error(
|
||||
`Spec validation failed after ${maxRetries} retries:\n${issueText}`,
|
||||
);
|
||||
setError(validationError);
|
||||
onError?.(validationError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
onComplete?.(currentSpec);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
|
||||
@@ -323,7 +323,9 @@ Any prop value can use data-driven expressions that resolve at render time. The
|
||||
}
|
||||
```
|
||||
|
||||
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
|
||||
For two-way binding, use `{ "$bindState": "/path" }` on the natural value prop (e.g. `value`, `checked`, `pressed`). Inside repeat scopes, use `{ "$bindItem": "field" }` instead.
|
||||
|
||||
To render a filtered list (kanban columns, status sections), put `repeat` and an `$item` visibility condition on the same container: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }` renders one child per matching item. AND-composed `$state` conjuncts still gate the container itself. Components receive resolved `bindings` with the state path for each bound prop; use `useBoundProp(props.value, bindings?.value)` to get `[value, setValue]`.
|
||||
|
||||
### `$template` and `$computed`
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
resolveElementProps,
|
||||
resolveBindings,
|
||||
resolveActionParam,
|
||||
splitRepeatVisibility,
|
||||
evaluateVisibility,
|
||||
getByPath,
|
||||
isDevtoolsActive,
|
||||
@@ -226,11 +227,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
return base;
|
||||
}, [ctx, repeatScope, functions, directives]);
|
||||
|
||||
// A repeat container whose own visible condition references $item/$index
|
||||
// outside any repeat scope is (partly) a per-item filter: models and humans
|
||||
// write {"repeat": ..., "visible": {"$item": "status", "eq": "todo"}} to
|
||||
// mean a filtered list. AND-composed $state conjuncts still gate the
|
||||
// container itself so a false gate hides the shell, not just the items.
|
||||
const repeatVisibility =
|
||||
element.repeat !== undefined && repeatScope == null
|
||||
? splitRepeatVisibility(element.visible)
|
||||
: { container: element.visible, itemFilter: undefined };
|
||||
const repeatItemFilter = repeatVisibility.itemFilter;
|
||||
|
||||
// Evaluate visibility (now supports $item/$index inside repeat scopes)
|
||||
const isVisible =
|
||||
element.visible === undefined
|
||||
repeatVisibility.container === undefined
|
||||
? true
|
||||
: evaluateVisibility(element.visible, fullCtx);
|
||||
: evaluateVisibility(repeatVisibility.container, fullCtx);
|
||||
|
||||
// Create emit function that resolves events to action bindings.
|
||||
// Must be called before any early return to satisfy Rules of Hooks.
|
||||
@@ -391,6 +403,7 @@ const ElementRenderer = React.memo(function ElementRenderer({
|
||||
registry={registry}
|
||||
loading={loading}
|
||||
fallback={fallback}
|
||||
itemFilter={repeatItemFilter}
|
||||
/>
|
||||
) : (
|
||||
resolvedElement.children?.map((childKey) => {
|
||||
@@ -459,22 +472,40 @@ function RepeatChildren({
|
||||
registry,
|
||||
loading,
|
||||
fallback,
|
||||
itemFilter,
|
||||
}: {
|
||||
element: UIElement;
|
||||
spec: Spec;
|
||||
registry: ComponentRegistry;
|
||||
loading?: boolean;
|
||||
fallback?: ComponentRenderer;
|
||||
itemFilter?: UIElement["visible"];
|
||||
}) {
|
||||
const { state } = useStateStore();
|
||||
const { ctx } = useVisibility();
|
||||
const repeat = element.repeat!;
|
||||
const statePath = repeat.statePath;
|
||||
|
||||
const items = (getByPath(state, statePath) as unknown[] | undefined) ?? [];
|
||||
|
||||
// Per-item filter from the container's own $item/$index visible condition.
|
||||
// Original indices are preserved so item state paths still point at the
|
||||
// right array entry.
|
||||
const entries = items
|
||||
.map((itemValue, index) => ({ itemValue, index }))
|
||||
.filter(
|
||||
({ itemValue, index }) =>
|
||||
itemFilter === undefined ||
|
||||
evaluateVisibility(itemFilter, {
|
||||
...ctx,
|
||||
repeatItem: itemValue,
|
||||
repeatIndex: index,
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((itemValue, index) => {
|
||||
{entries.map(({ itemValue, index }) => {
|
||||
// Use a stable key: prefer key field, fall back to index
|
||||
const key =
|
||||
repeat.key && typeof itemValue === "object" && itemValue !== null
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import {
|
||||
JSONUIProvider,
|
||||
Renderer,
|
||||
type ComponentRenderProps,
|
||||
} from "./renderer";
|
||||
|
||||
function Stack({ children }: ComponentRenderProps) {
|
||||
return <div data-testid="stack">{children}</div>;
|
||||
}
|
||||
|
||||
function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
|
||||
return <span data-testid="text">{String(element.props.text)}</span>;
|
||||
}
|
||||
|
||||
const registry = { Stack, Text };
|
||||
|
||||
const tasks = [
|
||||
{ id: "1", title: "Auth flow", status: "todo" },
|
||||
{ id: "2", title: "Push notifications", status: "in-progress" },
|
||||
{ id: "3", title: "Dark mode", status: "todo" },
|
||||
{ id: "4", title: "App icon", status: "done" },
|
||||
];
|
||||
|
||||
function mount(spec: Spec) {
|
||||
return render(
|
||||
<JSONUIProvider registry={registry} initialState={spec.state ?? {}}>
|
||||
<Renderer spec={spec} registry={registry} />
|
||||
</JSONUIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("repeat with $item visible on the container (filtered list)", () => {
|
||||
it("renders only the items matching the filter", () => {
|
||||
mount({
|
||||
root: "column",
|
||||
state: { tasks },
|
||||
elements: {
|
||||
column: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/tasks", key: "id" },
|
||||
visible: { $item: "status", eq: "todo" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "title" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const texts = screen.getAllByTestId("text").map((n) => n.textContent);
|
||||
expect(texts).toEqual(["Auth flow", "Dark mode"]);
|
||||
});
|
||||
|
||||
it("renders every item without a filter", () => {
|
||||
mount({
|
||||
root: "column",
|
||||
state: { tasks },
|
||||
elements: {
|
||||
column: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/tasks", key: "id" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "title" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getAllByTestId("text")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("still hides the whole list for a container-level $state condition", () => {
|
||||
mount({
|
||||
root: "column",
|
||||
state: { tasks, showTasks: false },
|
||||
elements: {
|
||||
column: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/tasks", key: "id" },
|
||||
visible: { $state: "/showTasks", eq: true },
|
||||
children: ["card"],
|
||||
},
|
||||
card: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "title" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.queryAllByTestId("text")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps per-child $item visible working inside the repeat scope", () => {
|
||||
mount({
|
||||
root: "column",
|
||||
state: { tasks },
|
||||
elements: {
|
||||
column: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/tasks", key: "id" },
|
||||
children: ["card"],
|
||||
},
|
||||
card: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "title" } },
|
||||
visible: { $item: "status", eq: "done" },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const texts = screen.getAllByTestId("text").map((n) => n.textContent);
|
||||
expect(texts).toEqual(["App icon"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed container/item visibility on repeat containers", () => {
|
||||
const mixedSpec = (showTasks: boolean): Spec => ({
|
||||
root: "column",
|
||||
state: { tasks, showTasks },
|
||||
elements: {
|
||||
column: {
|
||||
type: "Stack",
|
||||
props: {},
|
||||
repeat: { statePath: "/tasks", key: "id" },
|
||||
visible: {
|
||||
$and: [
|
||||
{ $state: "/showTasks", eq: true },
|
||||
{ $item: "status", eq: "todo" },
|
||||
],
|
||||
},
|
||||
children: ["card"],
|
||||
},
|
||||
card: {
|
||||
type: "Text",
|
||||
props: { text: { $item: "title" } },
|
||||
children: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("hides the container shell when the $state gate is false", () => {
|
||||
mount(mixedSpec(false));
|
||||
expect(screen.queryAllByTestId("stack")).toHaveLength(0);
|
||||
expect(screen.queryAllByTestId("text")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("filters items when the $state gate is true", () => {
|
||||
mount(mixedSpec(true));
|
||||
expect(screen.getAllByTestId("stack")).toHaveLength(1);
|
||||
const texts = screen.getAllByTestId("text").map((n) => n.textContent);
|
||||
expect(texts).toEqual(["Auth flow", "Dark mode"]);
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ export const schema = defineSchema(
|
||||
"CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.",
|
||||
"SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.",
|
||||
'REQUIRED FIELDS: Every element MUST include a "children" array. Leaf elements (text, badges, inputs, images) use an empty array: "children": []. Omitting "children" fails validation.',
|
||||
'FILTERED LISTS: To render only the items matching a field value (kanban columns, tabbed lists, status sections), put "repeat" and a "visible" condition with $item on the same container element: {"repeat": {"statePath": "/tasks", "key": "id"}, "visible": {"$item": "status", "eq": "todo"}} renders one child per matching item. A visible condition object must use exactly one of $state, $item, or $index — never combine them in one object.',
|
||||
|
||||
// Field placement
|
||||
'CRITICAL: The "visible" field goes on the ELEMENT object, NOT inside "props". Correct: {"type":"<ComponentName>","props":{},"visible":{"$state":"/tab","eq":"home"},"children":[...]}.',
|
||||
|
||||
@@ -176,7 +176,14 @@ Validate spec structure and auto-fix common issues:
|
||||
import { validateSpec, autoFixSpec } from "@json-render/core";
|
||||
|
||||
const { valid, issues } = validateSpec(spec);
|
||||
const fixed = autoFixSpec(spec);
|
||||
// issues include: missing_child, invalid_visible (malformed conditions),
|
||||
// repeat_without_children, repeat_state_mismatch (statePath not an array in state)
|
||||
|
||||
const { spec: fixed, fixDetails } = autoFixSpec(spec);
|
||||
// fixDetails entries are { message, lossy }. Lossless fixes relocate
|
||||
// misplaced fields; lossy fixes prune dangling children references.
|
||||
// In a repair loop, withhold lossy fixes until retries are exhausted:
|
||||
const attempt = autoFixSpec(spec, { lossy: retriesExhausted });
|
||||
```
|
||||
|
||||
## Visibility Conditions
|
||||
@@ -253,7 +260,7 @@ The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`,
|
||||
| `diffToPatches` | Generate RFC 6902 JSON Patch operations from object diff |
|
||||
| `EditMode` | Type: `"patch" \| "merge" \| "diff"` |
|
||||
| `validateSpec` | Validate spec structure |
|
||||
| `autoFixSpec` | Auto-fix common spec issues |
|
||||
| `autoFixSpec` | Auto-fix common spec issues; classifies fixes lossy/lossless, `{ lossy: false }` withholds pruning |
|
||||
| `createSpecStreamCompiler` | Stream JSONL patches into spec |
|
||||
| `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams |
|
||||
| `parseSpecStreamLine` | Parse single JSONL line |
|
||||
|
||||
@@ -118,6 +118,7 @@ Any prop value can be a data-driven expression resolved by the renderer before c
|
||||
- **`{ "$state": "/state/key" }`** - reads from state model (one-way read)
|
||||
- **`{ "$bindState": "/path" }`** - two-way binding: reads from state and enables write-back. Use on the natural value prop (value, checked, pressed, etc.) of form components.
|
||||
- **`{ "$bindItem": "field" }`** - two-way binding to a repeat item field. Use inside repeat scopes.
|
||||
- **Filtered lists**: `repeat` plus an `$item` visible condition on the same container renders only matching items: `{ "repeat": { "statePath": "/tasks", "key": "id" }, "visible": { "$item": "status", "eq": "todo" }, "children": ["task-card"] }`. AND-composed `$state` conjuncts gate the container shell; `$item`/`$index` conjuncts filter items.
|
||||
- **`{ "$cond": <condition>, "$then": <value>, "$else": <value> }`** - conditional value
|
||||
- **`{ "$template": "Hello, ${/name}!" }`** - interpolates state values into strings
|
||||
- **`{ "$computed": "fn", "args": { ... } }`** - calls registered functions with resolved args
|
||||
|
||||
Reference in New Issue
Block a user