@json-render/core 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -19,26 +19,36 @@ var DynamicBooleanSchema = z.union([
19
19
  z.boolean(),
20
20
  z.object({ path: z.string() })
21
21
  ]);
22
- function resolveDynamicValue(value, dataModel) {
22
+ function resolveDynamicValue(value, stateModel) {
23
23
  if (value === null || value === void 0) {
24
24
  return void 0;
25
25
  }
26
26
  if (typeof value === "object" && "path" in value) {
27
- return getByPath(dataModel, value.path);
27
+ return getByPath(stateModel, value.path);
28
28
  }
29
29
  return value;
30
30
  }
31
+ function unescapeJsonPointer(token) {
32
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
33
+ }
34
+ function parseJsonPointer(path) {
35
+ const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
36
+ return raw.map(unescapeJsonPointer);
37
+ }
31
38
  function getByPath(obj, path) {
32
39
  if (!path || path === "/") {
33
40
  return obj;
34
41
  }
35
- const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
42
+ const segments = parseJsonPointer(path);
36
43
  let current = obj;
37
44
  for (const segment of segments) {
38
45
  if (current === null || current === void 0) {
39
46
  return void 0;
40
47
  }
41
- if (typeof current === "object") {
48
+ if (Array.isArray(current)) {
49
+ const index = parseInt(segment, 10);
50
+ current = current[index];
51
+ } else if (typeof current === "object") {
42
52
  current = current[segment];
43
53
  } else {
44
54
  return void 0;
@@ -50,13 +60,13 @@ function isNumericIndex(str) {
50
60
  return /^\d+$/.test(str);
51
61
  }
52
62
  function setByPath(obj, path, value) {
53
- const segments = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
63
+ const segments = parseJsonPointer(path);
54
64
  if (segments.length === 0) return;
55
65
  let current = obj;
56
66
  for (let i = 0; i < segments.length - 1; i++) {
57
67
  const segment = segments[i];
58
68
  const nextSegment = segments[i + 1];
59
- const nextIsNumeric = nextSegment !== void 0 && isNumericIndex(nextSegment);
69
+ const nextIsNumeric = nextSegment !== void 0 && (isNumericIndex(nextSegment) || nextSegment === "-");
60
70
  if (Array.isArray(current)) {
61
71
  const index = parseInt(segment, 10);
62
72
  if (current[index] === void 0 || typeof current[index] !== "object") {
@@ -72,12 +82,95 @@ function setByPath(obj, path, value) {
72
82
  }
73
83
  const lastSegment = segments[segments.length - 1];
74
84
  if (Array.isArray(current)) {
75
- const index = parseInt(lastSegment, 10);
76
- current[index] = value;
85
+ if (lastSegment === "-") {
86
+ current.push(value);
87
+ } else {
88
+ const index = parseInt(lastSegment, 10);
89
+ current[index] = value;
90
+ }
77
91
  } else {
78
92
  current[lastSegment] = value;
79
93
  }
80
94
  }
95
+ function addByPath(obj, path, value) {
96
+ const segments = parseJsonPointer(path);
97
+ if (segments.length === 0) return;
98
+ let current = obj;
99
+ for (let i = 0; i < segments.length - 1; i++) {
100
+ const segment = segments[i];
101
+ const nextSegment = segments[i + 1];
102
+ const nextIsNumeric = nextSegment !== void 0 && (isNumericIndex(nextSegment) || nextSegment === "-");
103
+ if (Array.isArray(current)) {
104
+ const index = parseInt(segment, 10);
105
+ if (current[index] === void 0 || typeof current[index] !== "object") {
106
+ current[index] = nextIsNumeric ? [] : {};
107
+ }
108
+ current = current[index];
109
+ } else {
110
+ if (!(segment in current) || typeof current[segment] !== "object") {
111
+ current[segment] = nextIsNumeric ? [] : {};
112
+ }
113
+ current = current[segment];
114
+ }
115
+ }
116
+ const lastSegment = segments[segments.length - 1];
117
+ if (Array.isArray(current)) {
118
+ if (lastSegment === "-") {
119
+ current.push(value);
120
+ } else {
121
+ const index = parseInt(lastSegment, 10);
122
+ current.splice(index, 0, value);
123
+ }
124
+ } else {
125
+ current[lastSegment] = value;
126
+ }
127
+ }
128
+ function removeByPath(obj, path) {
129
+ const segments = parseJsonPointer(path);
130
+ if (segments.length === 0) return;
131
+ let current = obj;
132
+ for (let i = 0; i < segments.length - 1; i++) {
133
+ const segment = segments[i];
134
+ if (Array.isArray(current)) {
135
+ const index = parseInt(segment, 10);
136
+ if (current[index] === void 0 || typeof current[index] !== "object") {
137
+ return;
138
+ }
139
+ current = current[index];
140
+ } else {
141
+ if (!(segment in current) || typeof current[segment] !== "object") {
142
+ return;
143
+ }
144
+ current = current[segment];
145
+ }
146
+ }
147
+ const lastSegment = segments[segments.length - 1];
148
+ if (Array.isArray(current)) {
149
+ const index = parseInt(lastSegment, 10);
150
+ if (index >= 0 && index < current.length) {
151
+ current.splice(index, 1);
152
+ }
153
+ } else {
154
+ delete current[lastSegment];
155
+ }
156
+ }
157
+ function deepEqual(a, b) {
158
+ if (a === b) return true;
159
+ if (a === null || b === null) return false;
160
+ if (typeof a !== typeof b) return false;
161
+ if (typeof a !== "object") return false;
162
+ if (Array.isArray(a)) {
163
+ if (!Array.isArray(b)) return false;
164
+ if (a.length !== b.length) return false;
165
+ return a.every((item, i) => deepEqual(item, b[i]));
166
+ }
167
+ const aObj = a;
168
+ const bObj = b;
169
+ const aKeys = Object.keys(aObj);
170
+ const bKeys = Object.keys(bObj);
171
+ if (aKeys.length !== bKeys.length) return false;
172
+ return aKeys.every((key) => deepEqual(aObj[key], bObj[key]));
173
+ }
81
174
  function findFormValue(fieldName, params, data) {
82
175
  if (params?.[fieldName] !== void 0) {
83
176
  const val = params[fieldName];
@@ -126,10 +219,38 @@ function parseSpecStreamLine(line) {
126
219
  }
127
220
  }
128
221
  function applySpecStreamPatch(obj, patch) {
129
- if (patch.op === "set" || patch.op === "add" || patch.op === "replace") {
130
- setByPath(obj, patch.path, patch.value);
131
- } else if (patch.op === "remove") {
132
- setByPath(obj, patch.path, void 0);
222
+ switch (patch.op) {
223
+ case "add":
224
+ addByPath(obj, patch.path, patch.value);
225
+ break;
226
+ case "replace":
227
+ setByPath(obj, patch.path, patch.value);
228
+ break;
229
+ case "remove":
230
+ removeByPath(obj, patch.path);
231
+ break;
232
+ case "move": {
233
+ if (!patch.from) break;
234
+ const moveValue = getByPath(obj, patch.from);
235
+ removeByPath(obj, patch.from);
236
+ addByPath(obj, patch.path, moveValue);
237
+ break;
238
+ }
239
+ case "copy": {
240
+ if (!patch.from) break;
241
+ const copyValue = getByPath(obj, patch.from);
242
+ addByPath(obj, patch.path, copyValue);
243
+ break;
244
+ }
245
+ case "test": {
246
+ const actual = getByPath(obj, patch.path);
247
+ if (!deepEqual(actual, patch.value)) {
248
+ throw new Error(
249
+ `Test operation failed: value at "${patch.path}" does not match`
250
+ );
251
+ }
252
+ break;
253
+ }
133
254
  }
134
255
  return obj;
135
256
  }
@@ -231,7 +352,7 @@ var VisibilityConditionSchema = z2.union([
231
352
  LogicExpressionSchema
232
353
  ]);
233
354
  function evaluateLogicExpression(expr, ctx) {
234
- const { dataModel } = ctx;
355
+ const { stateModel } = ctx;
235
356
  if ("and" in expr) {
236
357
  return expr.and.every((subExpr) => evaluateLogicExpression(subExpr, ctx));
237
358
  }
@@ -242,30 +363,30 @@ function evaluateLogicExpression(expr, ctx) {
242
363
  return !evaluateLogicExpression(expr.not, ctx);
243
364
  }
244
365
  if ("path" in expr) {
245
- const value = resolveDynamicValue({ path: expr.path }, dataModel);
366
+ const value = resolveDynamicValue({ path: expr.path }, stateModel);
246
367
  return Boolean(value);
247
368
  }
248
369
  if ("eq" in expr) {
249
370
  const [left, right] = expr.eq;
250
- const leftValue = resolveDynamicValue(left, dataModel);
251
- const rightValue = resolveDynamicValue(right, dataModel);
371
+ const leftValue = resolveDynamicValue(left, stateModel);
372
+ const rightValue = resolveDynamicValue(right, stateModel);
252
373
  return leftValue === rightValue;
253
374
  }
254
375
  if ("neq" in expr) {
255
376
  const [left, right] = expr.neq;
256
- const leftValue = resolveDynamicValue(left, dataModel);
257
- const rightValue = resolveDynamicValue(right, dataModel);
377
+ const leftValue = resolveDynamicValue(left, stateModel);
378
+ const rightValue = resolveDynamicValue(right, stateModel);
258
379
  return leftValue !== rightValue;
259
380
  }
260
381
  if ("gt" in expr) {
261
382
  const [left, right] = expr.gt;
262
383
  const leftValue = resolveDynamicValue(
263
384
  left,
264
- dataModel
385
+ stateModel
265
386
  );
266
387
  const rightValue = resolveDynamicValue(
267
388
  right,
268
- dataModel
389
+ stateModel
269
390
  );
270
391
  if (typeof leftValue === "number" && typeof rightValue === "number") {
271
392
  return leftValue > rightValue;
@@ -276,11 +397,11 @@ function evaluateLogicExpression(expr, ctx) {
276
397
  const [left, right] = expr.gte;
277
398
  const leftValue = resolveDynamicValue(
278
399
  left,
279
- dataModel
400
+ stateModel
280
401
  );
281
402
  const rightValue = resolveDynamicValue(
282
403
  right,
283
- dataModel
404
+ stateModel
284
405
  );
285
406
  if (typeof leftValue === "number" && typeof rightValue === "number") {
286
407
  return leftValue >= rightValue;
@@ -291,11 +412,11 @@ function evaluateLogicExpression(expr, ctx) {
291
412
  const [left, right] = expr.lt;
292
413
  const leftValue = resolveDynamicValue(
293
414
  left,
294
- dataModel
415
+ stateModel
295
416
  );
296
417
  const rightValue = resolveDynamicValue(
297
418
  right,
298
- dataModel
419
+ stateModel
299
420
  );
300
421
  if (typeof leftValue === "number" && typeof rightValue === "number") {
301
422
  return leftValue < rightValue;
@@ -306,11 +427,11 @@ function evaluateLogicExpression(expr, ctx) {
306
427
  const [left, right] = expr.lte;
307
428
  const leftValue = resolveDynamicValue(
308
429
  left,
309
- dataModel
430
+ stateModel
310
431
  );
311
432
  const rightValue = resolveDynamicValue(
312
433
  right,
313
- dataModel
434
+ stateModel
314
435
  );
315
436
  if (typeof leftValue === "number" && typeof rightValue === "number") {
316
437
  return leftValue <= rightValue;
@@ -327,7 +448,7 @@ function evaluateVisibility(condition, ctx) {
327
448
  return condition;
328
449
  }
329
450
  if ("path" in condition && !("and" in condition) && !("or" in condition)) {
330
- const value = resolveDynamicValue({ path: condition.path }, ctx.dataModel);
451
+ const value = resolveDynamicValue({ path: condition.path }, ctx.stateModel);
331
452
  return Boolean(value);
332
453
  }
333
454
  if ("auth" in condition) {
@@ -381,6 +502,44 @@ var visibility = {
381
502
  lte: (left, right) => ({ lte: [left, right] })
382
503
  };
383
504
 
505
+ // src/props.ts
506
+ function isPathExpression(value) {
507
+ return typeof value === "object" && value !== null && "$path" in value && typeof value.$path === "string";
508
+ }
509
+ function isCondExpression(value) {
510
+ return typeof value === "object" && value !== null && "$cond" in value && "$then" in value && "$else" in value;
511
+ }
512
+ function resolvePropValue(value, ctx) {
513
+ if (value === null || value === void 0) {
514
+ return value;
515
+ }
516
+ if (isPathExpression(value)) {
517
+ return getByPath(ctx.stateModel, value.$path);
518
+ }
519
+ if (isCondExpression(value)) {
520
+ const result = evaluateVisibility(value.$cond, ctx);
521
+ return resolvePropValue(result ? value.$then : value.$else, ctx);
522
+ }
523
+ if (Array.isArray(value)) {
524
+ return value.map((item) => resolvePropValue(item, ctx));
525
+ }
526
+ if (typeof value === "object") {
527
+ const resolved = {};
528
+ for (const [key, val] of Object.entries(value)) {
529
+ resolved[key] = resolvePropValue(val, ctx);
530
+ }
531
+ return resolved;
532
+ }
533
+ return value;
534
+ }
535
+ function resolveElementProps(props, ctx) {
536
+ const resolved = {};
537
+ for (const [key, value] of Object.entries(props)) {
538
+ resolved[key] = resolvePropValue(value, ctx);
539
+ }
540
+ return resolved;
541
+ }
542
+
384
543
  // src/actions.ts
385
544
  import { z as z3 } from "zod";
386
545
  var ActionConfirmSchema = z3.object({
@@ -399,44 +558,45 @@ var ActionOnErrorSchema = z3.union([
399
558
  z3.object({ set: z3.record(z3.string(), z3.unknown()) }),
400
559
  z3.object({ action: z3.string() })
401
560
  ]);
402
- var ActionSchema = z3.object({
403
- name: z3.string(),
561
+ var ActionBindingSchema = z3.object({
562
+ action: z3.string(),
404
563
  params: z3.record(z3.string(), DynamicValueSchema).optional(),
405
564
  confirm: ActionConfirmSchema.optional(),
406
565
  onSuccess: ActionOnSuccessSchema.optional(),
407
566
  onError: ActionOnErrorSchema.optional()
408
567
  });
409
- function resolveAction(action2, dataModel) {
568
+ var ActionSchema = ActionBindingSchema;
569
+ function resolveAction(binding, stateModel) {
410
570
  const resolvedParams = {};
411
- if (action2.params) {
412
- for (const [key, value] of Object.entries(action2.params)) {
413
- resolvedParams[key] = resolveDynamicValue(value, dataModel);
571
+ if (binding.params) {
572
+ for (const [key, value] of Object.entries(binding.params)) {
573
+ resolvedParams[key] = resolveDynamicValue(value, stateModel);
414
574
  }
415
575
  }
416
- let confirm = action2.confirm;
576
+ let confirm = binding.confirm;
417
577
  if (confirm) {
418
578
  confirm = {
419
579
  ...confirm,
420
- message: interpolateString(confirm.message, dataModel),
421
- title: interpolateString(confirm.title, dataModel)
580
+ message: interpolateString(confirm.message, stateModel),
581
+ title: interpolateString(confirm.title, stateModel)
422
582
  };
423
583
  }
424
584
  return {
425
- name: action2.name,
585
+ action: binding.action,
426
586
  params: resolvedParams,
427
587
  confirm,
428
- onSuccess: action2.onSuccess,
429
- onError: action2.onError
588
+ onSuccess: binding.onSuccess,
589
+ onError: binding.onError
430
590
  };
431
591
  }
432
- function interpolateString(template, dataModel) {
592
+ function interpolateString(template, stateModel) {
433
593
  return template.replace(/\$\{([^}]+)\}/g, (_, path) => {
434
- const value = resolveDynamicValue({ path }, dataModel);
594
+ const value = resolveDynamicValue({ path }, stateModel);
435
595
  return String(value ?? "");
436
596
  });
437
597
  }
438
598
  async function executeAction(ctx) {
439
- const { action: action2, handler, setData, navigate, executeAction: executeAction2 } = ctx;
599
+ const { action: action2, handler, setState, navigate, executeAction: executeAction2 } = ctx;
440
600
  try {
441
601
  await handler(action2.params);
442
602
  if (action2.onSuccess) {
@@ -444,7 +604,7 @@ async function executeAction(ctx) {
444
604
  navigate(action2.onSuccess.navigate);
445
605
  } else if ("set" in action2.onSuccess) {
446
606
  for (const [path, value] of Object.entries(action2.onSuccess.set)) {
447
- setData(path, value);
607
+ setState(path, value);
448
608
  }
449
609
  } else if ("action" in action2.onSuccess && executeAction2) {
450
610
  await executeAction2(action2.onSuccess.action);
@@ -455,7 +615,7 @@ async function executeAction(ctx) {
455
615
  if ("set" in action2.onError) {
456
616
  for (const [path, value] of Object.entries(action2.onError.set)) {
457
617
  const resolvedValue = typeof value === "string" && value === "$error.message" ? error.message : value;
458
- setData(path, resolvedValue);
618
+ setState(path, resolvedValue);
459
619
  }
460
620
  } else if ("action" in action2.onError && executeAction2) {
461
621
  await executeAction2(action2.onError.action);
@@ -465,25 +625,26 @@ async function executeAction(ctx) {
465
625
  }
466
626
  }
467
627
  }
468
- var action = {
469
- /** Create a simple action */
470
- simple: (name, params) => ({
471
- name,
628
+ var actionBinding = {
629
+ /** Create a simple action binding */
630
+ simple: (actionName, params) => ({
631
+ action: actionName,
472
632
  params
473
633
  }),
474
- /** Create an action with confirmation */
475
- withConfirm: (name, confirm, params) => ({
476
- name,
634
+ /** Create an action binding with confirmation */
635
+ withConfirm: (actionName, confirm, params) => ({
636
+ action: actionName,
477
637
  params,
478
638
  confirm
479
639
  }),
480
- /** Create an action with success handler */
481
- withSuccess: (name, onSuccess, params) => ({
482
- name,
640
+ /** Create an action binding with success handler */
641
+ withSuccess: (actionName, onSuccess, params) => ({
642
+ action: actionName,
483
643
  params,
484
644
  onSuccess
485
645
  })
486
646
  };
647
+ var action = actionBinding;
487
648
 
488
649
  // src/validation.ts
489
650
  import { z as z4 } from "zod";
@@ -592,11 +753,11 @@ var builtInValidationFunctions = {
592
753
  }
593
754
  };
594
755
  function runValidationCheck(check2, ctx) {
595
- const { value, dataModel, customFunctions } = ctx;
756
+ const { value, stateModel, customFunctions } = ctx;
596
757
  const resolvedArgs = {};
597
758
  if (check2.args) {
598
759
  for (const [key, argValue] of Object.entries(check2.args)) {
599
- resolvedArgs[key] = resolveDynamicValue(argValue, dataModel);
760
+ resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
600
761
  }
601
762
  }
602
763
  const fn = builtInValidationFunctions[check2.fn] ?? customFunctions?.[check2.fn];
@@ -621,7 +782,7 @@ function runValidation(config, ctx) {
621
782
  const errors = [];
622
783
  if (config.enabled) {
623
784
  const enabled = evaluateLogicExpression(config.enabled, {
624
- dataModel: ctx.dataModel,
785
+ stateModel: ctx.stateModel,
625
786
  authState: ctx.authState
626
787
  });
627
788
  if (!enabled) {
@@ -688,6 +849,155 @@ var check = {
688
849
  })
689
850
  };
690
851
 
852
+ // src/spec-validator.ts
853
+ function validateSpec(spec, options = {}) {
854
+ const { checkOrphans = false } = options;
855
+ const issues = [];
856
+ if (!spec.root) {
857
+ issues.push({
858
+ severity: "error",
859
+ message: "Spec has no root element defined.",
860
+ code: "missing_root"
861
+ });
862
+ return { valid: false, issues };
863
+ }
864
+ if (!spec.elements[spec.root]) {
865
+ issues.push({
866
+ severity: "error",
867
+ message: `Root element "${spec.root}" not found in elements map.`,
868
+ code: "root_not_found"
869
+ });
870
+ }
871
+ if (Object.keys(spec.elements).length === 0) {
872
+ issues.push({
873
+ severity: "error",
874
+ message: "Spec has no elements.",
875
+ code: "empty_spec"
876
+ });
877
+ return { valid: false, issues };
878
+ }
879
+ for (const [key, element] of Object.entries(spec.elements)) {
880
+ if (element.children) {
881
+ for (const childKey of element.children) {
882
+ if (!spec.elements[childKey]) {
883
+ issues.push({
884
+ severity: "error",
885
+ message: `Element "${key}" references child "${childKey}" which does not exist in the elements map.`,
886
+ elementKey: key,
887
+ code: "missing_child"
888
+ });
889
+ }
890
+ }
891
+ }
892
+ const props = element.props;
893
+ if (props && "visible" in props && props.visible !== void 0) {
894
+ issues.push({
895
+ severity: "error",
896
+ message: `Element "${key}" has "visible" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
897
+ elementKey: key,
898
+ code: "visible_in_props"
899
+ });
900
+ }
901
+ if (props && "on" in props && props.on !== void 0) {
902
+ issues.push({
903
+ severity: "error",
904
+ message: `Element "${key}" has "on" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
905
+ elementKey: key,
906
+ code: "on_in_props"
907
+ });
908
+ }
909
+ if (props && "repeat" in props && props.repeat !== void 0) {
910
+ issues.push({
911
+ severity: "error",
912
+ message: `Element "${key}" has "repeat" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
913
+ elementKey: key,
914
+ code: "repeat_in_props"
915
+ });
916
+ }
917
+ }
918
+ if (checkOrphans) {
919
+ const reachable = /* @__PURE__ */ new Set();
920
+ const walk = (key) => {
921
+ if (reachable.has(key)) return;
922
+ reachable.add(key);
923
+ const el = spec.elements[key];
924
+ if (el?.children) {
925
+ for (const childKey of el.children) {
926
+ if (spec.elements[childKey]) {
927
+ walk(childKey);
928
+ }
929
+ }
930
+ }
931
+ };
932
+ if (spec.elements[spec.root]) {
933
+ walk(spec.root);
934
+ }
935
+ for (const key of Object.keys(spec.elements)) {
936
+ if (!reachable.has(key)) {
937
+ issues.push({
938
+ severity: "warning",
939
+ message: `Element "${key}" is not reachable from root "${spec.root}".`,
940
+ elementKey: key,
941
+ code: "orphaned_element"
942
+ });
943
+ }
944
+ }
945
+ }
946
+ const hasErrors = issues.some((i) => i.severity === "error");
947
+ return { valid: !hasErrors, issues };
948
+ }
949
+ function autoFixSpec(spec) {
950
+ const fixes = [];
951
+ const fixedElements = {};
952
+ for (const [key, element] of Object.entries(spec.elements)) {
953
+ const props = element.props;
954
+ let fixed = element;
955
+ if (props && "visible" in props && props.visible !== void 0) {
956
+ const { visible, ...restProps } = fixed.props;
957
+ fixed = {
958
+ ...fixed,
959
+ props: restProps,
960
+ visible
961
+ };
962
+ fixes.push(`Moved "visible" from props to element level on "${key}".`);
963
+ }
964
+ let currentProps = fixed.props;
965
+ if (currentProps && "on" in currentProps && currentProps.on !== void 0) {
966
+ const { on, ...restProps } = currentProps;
967
+ fixed = {
968
+ ...fixed,
969
+ props: restProps,
970
+ on
971
+ };
972
+ fixes.push(`Moved "on" from props to element level on "${key}".`);
973
+ }
974
+ currentProps = fixed.props;
975
+ if (currentProps && "repeat" in currentProps && currentProps.repeat !== void 0) {
976
+ const { repeat, ...restProps } = currentProps;
977
+ fixed = {
978
+ ...fixed,
979
+ props: restProps,
980
+ repeat
981
+ };
982
+ fixes.push(`Moved "repeat" from props to element level on "${key}".`);
983
+ }
984
+ fixedElements[key] = fixed;
985
+ }
986
+ return {
987
+ spec: { root: spec.root, elements: fixedElements },
988
+ fixes
989
+ };
990
+ }
991
+ function formatSpecIssues(issues) {
992
+ const errors = issues.filter((i) => i.severity === "error");
993
+ if (errors.length === 0) return "";
994
+ const lines = ["The generated UI spec has the following errors:"];
995
+ for (const issue of errors) {
996
+ lines.push(`- ${issue.message}`);
997
+ }
998
+ return lines.join("\n");
999
+ }
1000
+
691
1001
  // src/schema.ts
692
1002
  import { z as z5 } from "zod";
693
1003
  function createBuilder() {
@@ -712,6 +1022,7 @@ function defineSchema(builder, options) {
712
1022
  return {
713
1023
  definition,
714
1024
  promptTemplate: options?.promptTemplate,
1025
+ defaultRules: options?.defaultRules,
715
1026
  createCatalog(catalog) {
716
1027
  return createCatalogFromSchema(this, catalog);
717
1028
  }
@@ -867,15 +1178,95 @@ function generatePrompt(catalog, options) {
867
1178
  "Output JSONL (one JSON object per line) with patches to build a UI tree."
868
1179
  );
869
1180
  lines.push(
870
- "Each line is a JSON patch operation. Start with the root, then add each element."
1181
+ "Each line is a JSON patch operation. Start with /root, then stream /elements and /state patches interleaved so the UI fills in progressively as it streams."
871
1182
  );
872
1183
  lines.push("");
873
1184
  lines.push("Example output (each line is a separate JSON object):");
874
1185
  lines.push("");
875
- lines.push(`{"op":"set","path":"/root","value":"card-1"}
876
- {"op":"set","path":"/elements/card-1","value":{"key":"card-1","type":"Card","props":{"title":"Dashboard"},"children":["metric-1","chart-1"],"parentKey":""}}
877
- {"op":"set","path":"/elements/metric-1","value":{"key":"metric-1","type":"Metric","props":{"label":"Revenue","valuePath":"analytics.revenue","format":"currency"},"children":[],"parentKey":"card-1"}}
878
- {"op":"set","path":"/elements/chart-1","value":{"key":"chart-1","type":"Chart","props":{"type":"bar","dataPath":"analytics.salesByRegion"},"children":[],"parentKey":"card-1"}}`);
1186
+ lines.push(`{"op":"add","path":"/root","value":"blog"}
1187
+ {"op":"add","path":"/elements/blog","value":{"type":"Stack","props":{"direction":"vertical","gap":"md"},"children":["heading","posts-grid"]}}
1188
+ {"op":"add","path":"/elements/heading","value":{"type":"Heading","props":{"text":"Blog","level":"h1"},"children":[]}}
1189
+ {"op":"add","path":"/elements/posts-grid","value":{"type":"Grid","props":{"columns":2,"gap":"md"},"repeat":{"path":"/posts","key":"id"},"children":["post-card"]}}
1190
+ {"op":"add","path":"/elements/post-card","value":{"type":"Card","props":{"title":{"$path":"$item/title"}},"children":["post-meta"]}}
1191
+ {"op":"add","path":"/elements/post-meta","value":{"type":"Text","props":{"text":{"$path":"$item/author"},"variant":"muted"},"children":[]}}
1192
+ {"op":"add","path":"/state/posts","value":[]}
1193
+ {"op":"add","path":"/state/posts/0","value":{"id":"1","title":"Getting Started","author":"Jane","date":"Jan 15"}}
1194
+ {"op":"add","path":"/state/posts/1","value":{"id":"2","title":"Advanced Tips","author":"Bob","date":"Feb 3"}}
1195
+
1196
+ Note: state patches appear right after the elements that use them, so the UI fills in as it streams.`);
1197
+ lines.push("");
1198
+ lines.push("INITIAL STATE:");
1199
+ lines.push(
1200
+ "Specs include a /state field to seed the state model. Components with statePath read from and write to this state, and $path expressions read from it."
1201
+ );
1202
+ lines.push(
1203
+ "CRITICAL: You MUST include state patches whenever your UI displays data via $path expressions, uses repeat to iterate over arrays, or uses statePath bindings. Without state, $path references resolve to nothing and repeat lists render zero items."
1204
+ );
1205
+ lines.push(
1206
+ "Output state patches right after the elements that reference them, so the UI fills in progressively as it streams."
1207
+ );
1208
+ lines.push(
1209
+ "Stream state progressively - output one patch per array item instead of one giant blob:"
1210
+ );
1211
+ lines.push(
1212
+ ' For arrays: {"op":"add","path":"/state/posts/0","value":{"id":"1","title":"First Post",...}} then /state/posts/1, /state/posts/2, etc.'
1213
+ );
1214
+ lines.push(
1215
+ ' For scalars: {"op":"add","path":"/state/newTodoText","value":""}'
1216
+ );
1217
+ lines.push(
1218
+ ' Initialize the array first if needed: {"op":"add","path":"/state/posts","value":[]}'
1219
+ );
1220
+ lines.push(
1221
+ 'When content comes from the state model, use { "$path": "/some/path" } dynamic props to display it instead of hardcoding the same value in both state and props. The state model is the single source of truth.'
1222
+ );
1223
+ lines.push(
1224
+ "Include realistic sample data in state. For blogs: 3-4 posts with titles, excerpts, authors, dates. For product lists: 3-5 items with names, prices, descriptions. Never leave arrays empty."
1225
+ );
1226
+ lines.push("");
1227
+ lines.push("DYNAMIC LISTS (repeat field):");
1228
+ lines.push(
1229
+ 'Any element can have a top-level "repeat" field to render its children once per item in a state array: { "repeat": { "path": "/arrayPath", "key": "id" } }.'
1230
+ );
1231
+ lines.push(
1232
+ 'The element itself renders once (as the container), and its children are expanded once per array item. "path" is the state array path. "key" is an optional field name on each item for stable React keys.'
1233
+ );
1234
+ lines.push(
1235
+ 'Example: { "type": "Column", "props": { "gap": 8 }, "repeat": { "path": "/todos", "key": "id" }, "children": ["todo-item"] }'
1236
+ );
1237
+ lines.push(
1238
+ 'Inside children of a repeated element, use "$item/field" for per-item paths: statePath:"$item/completed", { "$path": "$item/title" }. Use "$index" for the current array index.'
1239
+ );
1240
+ lines.push(
1241
+ "ALWAYS use the repeat field for lists backed by state arrays. NEVER hardcode individual elements for each array item."
1242
+ );
1243
+ lines.push(
1244
+ 'IMPORTANT: "repeat" is a top-level field on the element (sibling of type/props/children), NOT inside props.'
1245
+ );
1246
+ lines.push("");
1247
+ lines.push("ARRAY STATE ACTIONS:");
1248
+ lines.push(
1249
+ 'Use action "pushState" to append items to arrays. Params: { path: "/arrayPath", value: { ...item }, clearPath: "/inputPath" }.'
1250
+ );
1251
+ lines.push(
1252
+ 'Values inside pushState can contain { "path": "/statePath" } references to read current state (e.g. the text from an input field).'
1253
+ );
1254
+ lines.push(
1255
+ 'Use "$id" inside a pushState value to auto-generate a unique ID.'
1256
+ );
1257
+ lines.push(
1258
+ 'Example: on: { "press": { "action": "pushState", "params": { "path": "/todos", "value": { "id": "$id", "title": { "path": "/newTodoText" }, "completed": false }, "clearPath": "/newTodoText" } } }'
1259
+ );
1260
+ lines.push(
1261
+ `Use action "removeState" to remove items from arrays by index. Params: { path: "/arrayPath", index: N }. Inside a repeated element's children, use "$index" for the current item index.`
1262
+ );
1263
+ lines.push(
1264
+ "For lists where users can add/remove items (todos, carts, etc.), use pushState and removeState instead of hardcoding with setState."
1265
+ );
1266
+ lines.push("");
1267
+ lines.push(
1268
+ 'IMPORTANT: State paths use RFC 6901 JSON Pointer syntax (e.g. "/todos/0/title"). Do NOT use JavaScript-style dot notation (e.g. "/todos.length" is WRONG). To generate unique IDs for new items, use "$id" instead of trying to read array length.'
1269
+ );
879
1270
  lines.push("");
880
1271
  const components = catalog.data.components;
881
1272
  if (components) {
@@ -885,8 +1276,9 @@ function generatePrompt(catalog, options) {
885
1276
  const propsStr = def.props ? formatZodType(def.props) : "{}";
886
1277
  const hasChildren = def.slots && def.slots.length > 0;
887
1278
  const childrenStr = hasChildren ? " [accepts children]" : "";
1279
+ const eventsStr = def.events && def.events.length > 0 ? ` [events: ${def.events.join(", ")}]` : "";
888
1280
  const descStr = def.description ? ` - ${def.description}` : "";
889
- lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}`);
1281
+ lines.push(`- ${name}: ${propsStr}${descStr}${childrenStr}${eventsStr}`);
890
1282
  }
891
1283
  lines.push("");
892
1284
  }
@@ -899,17 +1291,90 @@ function generatePrompt(catalog, options) {
899
1291
  }
900
1292
  lines.push("");
901
1293
  }
1294
+ lines.push("EVENTS (the `on` field):");
1295
+ lines.push(
1296
+ "Elements can have an optional `on` field to bind events to actions. The `on` field is a top-level field on the element (sibling of type/props/children), NOT inside props."
1297
+ );
1298
+ lines.push(
1299
+ 'Each key in `on` is an event name (from the component\'s supported events), and the value is an action binding: `{ "action": "<actionName>", "params": { ... } }`.'
1300
+ );
1301
+ lines.push("");
1302
+ lines.push("Example:");
1303
+ lines.push(
1304
+ ' {"type":"Button","props":{"label":"Save"},"on":{"press":{"action":"setState","params":{"path":"/saved","value":true}}},"children":[]}'
1305
+ );
1306
+ lines.push("");
1307
+ lines.push(
1308
+ 'Action params can use dynamic path references to read from state: { "path": "/statePath" }.'
1309
+ );
1310
+ lines.push(
1311
+ "IMPORTANT: Do NOT put action/actionParams inside props. Always use the `on` field for event bindings."
1312
+ );
1313
+ lines.push("");
1314
+ lines.push("VISIBILITY CONDITIONS:");
1315
+ lines.push(
1316
+ "Elements can have an optional `visible` field to conditionally show/hide based on data state. IMPORTANT: `visible` is a top-level field on the element object (sibling of type/props/children), NOT inside props."
1317
+ );
1318
+ lines.push(
1319
+ 'Correct: {"type":"Column","props":{"gap":8},"visible":{"eq":[{"path":"/tab"},"home"]},"children":[...]}'
1320
+ );
1321
+ lines.push(
1322
+ '- `{ "eq": [{ "path": "/statePath" }, "value"] }` - visible when state at path equals value'
1323
+ );
1324
+ lines.push(
1325
+ '- `{ "neq": [{ "path": "/statePath" }, "value"] }` - visible when state at path does not equal value'
1326
+ );
1327
+ lines.push('- `{ "path": "/statePath" }` - visible when path is truthy');
1328
+ lines.push(
1329
+ '- `{ "and": [...] }`, `{ "or": [...] }`, `{ "not": {...} }` - combine conditions'
1330
+ );
1331
+ lines.push("- `true` / `false` - always visible/hidden");
1332
+ lines.push("");
1333
+ lines.push(
1334
+ "Use the Pressable component with on.press bound to setState to update state and drive visibility."
1335
+ );
1336
+ lines.push(
1337
+ 'Example: A Pressable with on: { "press": { "action": "setState", "params": { "path": "/activeTab", "value": "home" } } } sets state, then a container with visible: { "eq": [{ "path": "/activeTab" }, "home"] } shows only when that tab is active.'
1338
+ );
1339
+ lines.push("");
1340
+ lines.push("DYNAMIC PROPS:");
1341
+ lines.push(
1342
+ "Any prop value can be a dynamic expression that resolves based on state. Two forms are supported:"
1343
+ );
1344
+ lines.push("");
1345
+ lines.push(
1346
+ '1. State binding: `{ "$path": "/statePath" }` - resolves to the value at that state path.'
1347
+ );
1348
+ lines.push(
1349
+ ' Example: `"color": { "$path": "/theme/primary" }` reads the color from state.'
1350
+ );
1351
+ lines.push("");
1352
+ lines.push(
1353
+ '2. Conditional: `{ "$cond": <condition>, "$then": <value>, "$else": <value> }` - evaluates the condition (same syntax as visibility conditions) and picks the matching value.'
1354
+ );
1355
+ lines.push(
1356
+ ' Example: `"color": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "#007AFF", "$else": "#8E8E93" }`'
1357
+ );
1358
+ lines.push(
1359
+ ' Example: `"name": { "$cond": { "eq": [{ "path": "/activeTab" }, "home"] }, "$then": "home", "$else": "home-outline" }`'
1360
+ );
1361
+ lines.push("");
1362
+ lines.push(
1363
+ "Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ."
1364
+ );
1365
+ lines.push("");
902
1366
  lines.push("RULES:");
903
1367
  const baseRules = [
904
1368
  "Output ONLY JSONL patches - one JSON object per line, no markdown, no code fences",
905
- 'First line sets root: {"op":"set","path":"/root","value":"<root-key>"}',
906
- 'Then add each element: {"op":"set","path":"/elements/<key>","value":{...}}',
1369
+ 'First set root: {"op":"add","path":"/root","value":"<root-key>"}',
1370
+ 'Then add each element: {"op":"add","path":"/elements/<key>","value":{...}}',
1371
+ "Output /state patches right after the elements that use them, one per array item for progressive loading. REQUIRED whenever using $path, repeat, or statePath.",
907
1372
  "ONLY use components listed above",
908
- "Each element value needs: key, type, props, children (array of child keys), parentKey",
909
- "Use unique keys (e.g., 'header', 'metric-1', 'chart-revenue')",
910
- "Root element's parentKey is empty string, children reference their parent's key"
1373
+ "Each element value needs: type, props, children (array of child keys)",
1374
+ "Use unique keys for the element map entries (e.g., 'header', 'metric-1', 'chart-revenue')"
911
1375
  ];
912
- const allRules = [...baseRules, ...customRules];
1376
+ const schemaRules = catalog.schema.defaultRules ?? [];
1377
+ const allRules = [...baseRules, ...schemaRules, ...customRules];
913
1378
  allRules.forEach((rule, i) => {
914
1379
  lines.push(`${i + 1}. ${rule}`);
915
1380
  });
@@ -1047,6 +1512,56 @@ function defineCatalog(schema, catalog) {
1047
1512
  return schema.createCatalog(catalog);
1048
1513
  }
1049
1514
 
1515
+ // src/prompt.ts
1516
+ function isNonEmptySpec(spec) {
1517
+ if (!spec || typeof spec !== "object") return false;
1518
+ const s = spec;
1519
+ return typeof s.root === "string" && typeof s.elements === "object" && s.elements !== null && Object.keys(s.elements).length > 0;
1520
+ }
1521
+ var PATCH_INSTRUCTIONS = `IMPORTANT: The current UI is already loaded. Output ONLY the patches needed to make the requested change:
1522
+ - To add a new element: {"op":"add","path":"/elements/new-key","value":{...}}
1523
+ - To modify an existing element: {"op":"replace","path":"/elements/existing-key","value":{...}}
1524
+ - To remove an element: {"op":"remove","path":"/elements/old-key"}
1525
+ - To update the root: {"op":"replace","path":"/root","value":"new-root-key"}
1526
+ - To add children: update the parent element with new children array
1527
+
1528
+ DO NOT output patches for elements that don't need to change. Only output what's necessary for the requested modification.`;
1529
+ function buildUserPrompt(options) {
1530
+ const { prompt, currentSpec, state, maxPromptLength } = options;
1531
+ let userText = String(prompt || "");
1532
+ if (maxPromptLength !== void 0 && maxPromptLength > 0) {
1533
+ userText = userText.slice(0, maxPromptLength);
1534
+ }
1535
+ if (isNonEmptySpec(currentSpec)) {
1536
+ const parts2 = [];
1537
+ parts2.push(
1538
+ `CURRENT UI STATE (already loaded, DO NOT recreate existing elements):`
1539
+ );
1540
+ parts2.push(JSON.stringify(currentSpec, null, 2));
1541
+ parts2.push("");
1542
+ parts2.push(`USER REQUEST: ${userText}`);
1543
+ if (state && Object.keys(state).length > 0) {
1544
+ parts2.push("");
1545
+ parts2.push(`AVAILABLE STATE:
1546
+ ${JSON.stringify(state, null, 2)}`);
1547
+ }
1548
+ parts2.push("");
1549
+ parts2.push(PATCH_INSTRUCTIONS);
1550
+ return parts2.join("\n");
1551
+ }
1552
+ const parts = [userText];
1553
+ if (state && Object.keys(state).length > 0) {
1554
+ parts.push(`
1555
+ AVAILABLE STATE:
1556
+ ${JSON.stringify(state, null, 2)}`);
1557
+ }
1558
+ parts.push(
1559
+ `
1560
+ Remember: Output /root first, then interleave /elements and /state patches so the UI fills in progressively as it streams. Output each state patch right after the elements that use it, one per array item.`
1561
+ );
1562
+ return parts.join("\n");
1563
+ }
1564
+
1050
1565
  // src/catalog.ts
1051
1566
  import { z as z6 } from "zod";
1052
1567
  function createCatalog(config) {
@@ -1063,22 +1578,18 @@ function createCatalog(config) {
1063
1578
  const componentSchemas = componentNames.map((componentName) => {
1064
1579
  const def = components[componentName];
1065
1580
  return z6.object({
1066
- key: z6.string(),
1067
1581
  type: z6.literal(componentName),
1068
1582
  props: def.props,
1069
1583
  children: z6.array(z6.string()).optional(),
1070
- parentKey: z6.string().nullable().optional(),
1071
1584
  visible: VisibilityConditionSchema.optional()
1072
1585
  });
1073
1586
  });
1074
1587
  let elementSchema;
1075
1588
  if (componentSchemas.length === 0) {
1076
1589
  elementSchema = z6.object({
1077
- key: z6.string(),
1078
1590
  type: z6.string(),
1079
1591
  props: z6.record(z6.string(), z6.unknown()),
1080
1592
  children: z6.array(z6.string()).optional(),
1081
- parentKey: z6.string().nullable().optional(),
1082
1593
  visible: VisibilityConditionSchema.optional()
1083
1594
  });
1084
1595
  } else if (componentSchemas.length === 1) {
@@ -1160,7 +1671,7 @@ function generateCatalogPrompt(catalog) {
1160
1671
  lines.push("");
1161
1672
  lines.push("Components can have a `visible` property:");
1162
1673
  lines.push("- `true` / `false` - Always visible/hidden");
1163
- lines.push('- `{ "path": "/data/path" }` - Visible when path is truthy');
1674
+ lines.push('- `{ "path": "/state/path" }` - Visible when path is truthy');
1164
1675
  lines.push('- `{ "auth": "signedIn" }` - Visible when user is signed in');
1165
1676
  lines.push('- `{ "and": [...] }` - All conditions must be true');
1166
1677
  lines.push('- `{ "or": [...] }` - Any condition must be true');
@@ -1299,21 +1810,21 @@ function generateSystemPrompt(catalog, options = {}) {
1299
1810
  }
1300
1811
  lines.push("");
1301
1812
  }
1302
- lines.push("OUTPUT FORMAT (JSONL):");
1303
- lines.push('{"op":"set","path":"/root","value":"element-key"}');
1813
+ lines.push("OUTPUT FORMAT (JSONL, RFC 6902 JSON Patch):");
1814
+ lines.push('{"op":"add","path":"/root","value":"element-key"}');
1304
1815
  lines.push(
1305
- '{"op":"add","path":"/elements/key","value":{"key":"...","type":"...","props":{...},"children":[...]}}'
1816
+ '{"op":"add","path":"/elements/key","value":{"type":"...","props":{...},"children":[...]}}'
1306
1817
  );
1307
1818
  lines.push('{"op":"remove","path":"/elements/key"}');
1308
1819
  lines.push("");
1309
1820
  lines.push("RULES:");
1310
1821
  const baseRules = [
1311
- "First line sets /root to root element key",
1312
- "Add elements with /elements/{key}",
1822
+ 'First line sets /root to root element key: {"op":"add","path":"/root","value":"<key>"}',
1823
+ 'Add elements with /elements/{key}: {"op":"add","path":"/elements/<key>","value":{...}}',
1313
1824
  "Remove elements with op:remove - also update the parent's children array to exclude the removed key",
1314
1825
  "Children array contains string keys, not objects",
1315
1826
  "Parent first, then children",
1316
- "Each element needs: key, type, props",
1827
+ "Each element needs: type, props",
1317
1828
  "ONLY use props listed above - never invent new props"
1318
1829
  ];
1319
1830
  const allRules = [...baseRules, ...customRules];
@@ -1330,6 +1841,7 @@ function generateSystemPrompt(catalog, options = {}) {
1330
1841
  return lines.join("\n");
1331
1842
  }
1332
1843
  export {
1844
+ ActionBindingSchema,
1333
1845
  ActionConfirmSchema,
1334
1846
  ActionOnErrorSchema,
1335
1847
  ActionOnSuccessSchema,
@@ -1343,7 +1855,11 @@ export {
1343
1855
  ValidationConfigSchema,
1344
1856
  VisibilityConditionSchema,
1345
1857
  action,
1858
+ actionBinding,
1859
+ addByPath,
1346
1860
  applySpecStreamPatch,
1861
+ autoFixSpec,
1862
+ buildUserPrompt,
1347
1863
  builtInValidationFunctions,
1348
1864
  check,
1349
1865
  compileSpecStream,
@@ -1355,16 +1871,21 @@ export {
1355
1871
  evaluateVisibility,
1356
1872
  executeAction,
1357
1873
  findFormValue,
1874
+ formatSpecIssues,
1358
1875
  generateCatalogPrompt,
1359
1876
  generateSystemPrompt,
1360
1877
  getByPath,
1361
1878
  interpolateString,
1362
1879
  parseSpecStreamLine,
1880
+ removeByPath,
1363
1881
  resolveAction,
1364
1882
  resolveDynamicValue,
1883
+ resolveElementProps,
1884
+ resolvePropValue,
1365
1885
  runValidation,
1366
1886
  runValidationCheck,
1367
1887
  setByPath,
1888
+ validateSpec,
1368
1889
  visibility
1369
1890
  };
1370
1891
  //# sourceMappingURL=index.mjs.map