@json-render/core 0.9.1 → 0.11.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
@@ -21,7 +21,7 @@ import {
21
21
  removeByPath,
22
22
  resolveDynamicValue,
23
23
  setByPath
24
- } from "./chunk-4ZGEEX7K.mjs";
24
+ } from "./chunk-AFLK3Q4T.mjs";
25
25
 
26
26
  // src/visibility.ts
27
27
  import { z } from "zod";
@@ -206,6 +206,16 @@ function isBindItemExpression(value) {
206
206
  function isCondExpression(value) {
207
207
  return typeof value === "object" && value !== null && "$cond" in value && "$then" in value && "$else" in value;
208
208
  }
209
+ function isComputedExpression(value) {
210
+ return typeof value === "object" && value !== null && "$computed" in value && typeof value.$computed === "string";
211
+ }
212
+ function isTemplateExpression(value) {
213
+ return typeof value === "object" && value !== null && "$template" in value && typeof value.$template === "string";
214
+ }
215
+ var WARNED_COMPUTED_MAX = 100;
216
+ var warnedComputedFns = /* @__PURE__ */ new Set();
217
+ var WARNED_TEMPLATE_MAX = 100;
218
+ var warnedTemplatePaths = /* @__PURE__ */ new Set();
209
219
  function resolveBindItemPath(itemPath, ctx) {
210
220
  if (ctx.repeatBasePath == null) {
211
221
  console.warn(`$bindItem used outside repeat scope: "${itemPath}"`);
@@ -240,6 +250,46 @@ function resolvePropValue(value, ctx) {
240
250
  const result = evaluateVisibility(value.$cond, ctx);
241
251
  return resolvePropValue(result ? value.$then : value.$else, ctx);
242
252
  }
253
+ if (isComputedExpression(value)) {
254
+ const fn = ctx.functions?.[value.$computed];
255
+ if (!fn) {
256
+ if (!warnedComputedFns.has(value.$computed)) {
257
+ if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
258
+ warnedComputedFns.add(value.$computed);
259
+ }
260
+ console.warn(`Unknown $computed function: "${value.$computed}"`);
261
+ }
262
+ return void 0;
263
+ }
264
+ const resolvedArgs = {};
265
+ if (value.args) {
266
+ for (const [key, arg] of Object.entries(value.args)) {
267
+ resolvedArgs[key] = resolvePropValue(arg, ctx);
268
+ }
269
+ }
270
+ return fn(resolvedArgs);
271
+ }
272
+ if (isTemplateExpression(value)) {
273
+ return value.$template.replace(
274
+ /\$\{([^}]+)\}/g,
275
+ (_match, rawPath) => {
276
+ let path = rawPath;
277
+ if (!path.startsWith("/")) {
278
+ if (!warnedTemplatePaths.has(path)) {
279
+ if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
280
+ warnedTemplatePaths.add(path);
281
+ }
282
+ console.warn(
283
+ `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`
284
+ );
285
+ }
286
+ path = "/" + path;
287
+ }
288
+ const resolved = getByPath(ctx.stateModel, path);
289
+ return resolved != null ? String(resolved) : "";
290
+ }
291
+ );
292
+ }
243
293
  if (Array.isArray(value)) {
244
294
  return value.map((item) => resolvePropValue(item, ctx));
245
295
  }
@@ -404,6 +454,10 @@ var ValidationConfigSchema = z3.object({
404
454
  validateOn: z3.enum(["change", "blur", "submit"]).optional(),
405
455
  enabled: VisibilityConditionSchema.optional()
406
456
  });
457
+ var matchesImpl = (value, args) => {
458
+ const other = args?.other;
459
+ return value === other;
460
+ };
407
461
  var builtInValidationFunctions = {
408
462
  /**
409
463
  * Check if value is not null, undefined, or empty string
@@ -493,9 +547,60 @@ var builtInValidationFunctions = {
493
547
  /**
494
548
  * Check if value matches another field
495
549
  */
496
- matches: (value, args) => {
550
+ matches: matchesImpl,
551
+ /**
552
+ * Alias for matches with a more descriptive name for cross-field equality
553
+ */
554
+ equalTo: matchesImpl,
555
+ /**
556
+ * Check if value is less than another field's value.
557
+ * Supports numbers, strings (useful for ISO date comparison), and
558
+ * cross-type numeric coercion (e.g. string "3" vs number 5).
559
+ */
560
+ lessThan: (value, args) => {
561
+ const other = args?.other;
562
+ if (value == null || other == null || value === "" || other === "")
563
+ return false;
564
+ if (typeof value === "number" && typeof other === "number")
565
+ return value < other;
566
+ if (typeof value === "string" && typeof other === "string")
567
+ return value < other;
568
+ const numVal = Number(value);
569
+ const numOther = Number(other);
570
+ if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
571
+ return false;
572
+ },
573
+ /**
574
+ * Check if value is greater than another field's value.
575
+ * Supports numbers, strings (useful for ISO date comparison), and
576
+ * cross-type numeric coercion (e.g. string "7" vs number 5).
577
+ */
578
+ greaterThan: (value, args) => {
497
579
  const other = args?.other;
498
- return value === other;
580
+ if (value == null || other == null || value === "" || other === "")
581
+ return false;
582
+ if (typeof value === "number" && typeof other === "number")
583
+ return value > other;
584
+ if (typeof value === "string" && typeof other === "string")
585
+ return value > other;
586
+ const numVal = Number(value);
587
+ const numOther = Number(other);
588
+ if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
589
+ return false;
590
+ },
591
+ /**
592
+ * Required only when a condition is met.
593
+ * Uses JS truthiness: 0, false, "", null, and undefined are all
594
+ * treated as "condition not met" (field not required), matching
595
+ * the visibility system's bare-condition semantics.
596
+ */
597
+ requiredIf: (value, args) => {
598
+ const condition = args?.field;
599
+ if (!condition) return true;
600
+ if (value === null || value === void 0) return false;
601
+ if (typeof value === "string") return value.trim().length > 0;
602
+ if (Array.isArray(value)) return value.length > 0;
603
+ return true;
499
604
  }
500
605
  };
501
606
  function runValidationCheck(check2, ctx) {
@@ -503,7 +608,7 @@ function runValidationCheck(check2, ctx) {
503
608
  const resolvedArgs = {};
504
609
  if (check2.args) {
505
610
  for (const [key, argValue] of Object.entries(check2.args)) {
506
- resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
611
+ resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
507
612
  }
508
613
  }
509
614
  const validationFn = builtInValidationFunctions[check2.type] ?? customFunctions?.[check2.type];
@@ -587,10 +692,34 @@ var check = {
587
692
  type: "url",
588
693
  message
589
694
  }),
695
+ numeric: (message = "Must be a number") => ({
696
+ type: "numeric",
697
+ message
698
+ }),
590
699
  matches: (otherPath, message = "Fields must match") => ({
591
700
  type: "matches",
592
701
  args: { other: { $state: otherPath } },
593
702
  message
703
+ }),
704
+ equalTo: (otherPath, message = "Fields must match") => ({
705
+ type: "equalTo",
706
+ args: { other: { $state: otherPath } },
707
+ message
708
+ }),
709
+ lessThan: (otherPath, message) => ({
710
+ type: "lessThan",
711
+ args: { other: { $state: otherPath } },
712
+ message: message ?? "Must be less than the compared field"
713
+ }),
714
+ greaterThan: (otherPath, message) => ({
715
+ type: "greaterThan",
716
+ args: { other: { $state: otherPath } },
717
+ message: message ?? "Must be greater than the compared field"
718
+ }),
719
+ requiredIf: (fieldPath, message = "This field is required") => ({
720
+ type: "requiredIf",
721
+ args: { field: { $state: fieldPath } },
722
+ message
594
723
  })
595
724
  };
596
725
 
@@ -659,6 +788,14 @@ function validateSpec(spec, options = {}) {
659
788
  code: "repeat_in_props"
660
789
  });
661
790
  }
791
+ if (props && "watch" in props && props.watch !== void 0) {
792
+ issues.push({
793
+ severity: "error",
794
+ message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
795
+ elementKey: key,
796
+ code: "watch_in_props"
797
+ });
798
+ }
662
799
  }
663
800
  if (checkOrphans) {
664
801
  const reachable = /* @__PURE__ */ new Set();
@@ -726,6 +863,16 @@ function autoFixSpec(spec) {
726
863
  };
727
864
  fixes.push(`Moved "repeat" from props to element level on "${key}".`);
728
865
  }
866
+ currentProps = fixed.props;
867
+ if (currentProps && "watch" in currentProps && currentProps.watch !== void 0) {
868
+ const { watch, ...restProps } = currentProps;
869
+ fixed = {
870
+ ...fixed,
871
+ props: restProps,
872
+ watch
873
+ };
874
+ fixes.push(`Moved "watch" from props to element level on "${key}".`);
875
+ }
729
876
  fixedElements[key] = fixed;
730
877
  }
731
878
  return {
@@ -1211,6 +1358,29 @@ Note: state patches appear right after the elements that use them, so the UI fil
1211
1358
  "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ."
1212
1359
  );
1213
1360
  lines.push("");
1361
+ lines.push(
1362
+ '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.'
1363
+ );
1364
+ lines.push(
1365
+ ' Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.'
1366
+ );
1367
+ lines.push("");
1368
+ const catalogFunctions = catalog.data.functions;
1369
+ if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
1370
+ lines.push(
1371
+ '5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.'
1372
+ );
1373
+ lines.push(
1374
+ ' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`'
1375
+ );
1376
+ lines.push(" Available functions:");
1377
+ for (const name of Object.keys(
1378
+ catalogFunctions
1379
+ )) {
1380
+ lines.push(` - ${name}`);
1381
+ }
1382
+ lines.push("");
1383
+ }
1214
1384
  const hasChecksComponents = allComponents ? Object.entries(allComponents).some(([, def]) => {
1215
1385
  if (!def.props) return false;
1216
1386
  const formatted = formatZodType(def.props);
@@ -1236,7 +1406,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
1236
1406
  lines.push(" - numeric \u2014 value must be a number");
1237
1407
  lines.push(" - url \u2014 valid URL format");
1238
1408
  lines.push(
1239
- ' - matches \u2014 must equal another field (args: { "other": "value" })'
1409
+ ' - matches \u2014 must equal another field (args: { "other": { "$state": "/path" } })'
1410
+ );
1411
+ lines.push(
1412
+ ' - equalTo \u2014 alias for matches (args: { "other": { "$state": "/path" } })'
1413
+ );
1414
+ lines.push(
1415
+ ' - lessThan \u2014 value must be less than another field (args: { "other": { "$state": "/path" } })'
1416
+ );
1417
+ lines.push(
1418
+ ' - greaterThan \u2014 value must be greater than another field (args: { "other": { "$state": "/path" } })'
1419
+ );
1420
+ lines.push(
1421
+ ' - requiredIf \u2014 required only when another field is truthy (args: { "field": { "$state": "/path" } })'
1240
1422
  );
1241
1423
  lines.push("");
1242
1424
  lines.push("Example:");
@@ -1252,6 +1434,30 @@ Note: state patches appear right after the elements that use them, so the UI fil
1252
1434
  );
1253
1435
  lines.push("");
1254
1436
  }
1437
+ if (hasCustomActions || hasBuiltInActions) {
1438
+ lines.push("STATE WATCHERS:");
1439
+ lines.push(
1440
+ "Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props."
1441
+ );
1442
+ lines.push(
1443
+ "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically."
1444
+ );
1445
+ lines.push("");
1446
+ lines.push(
1447
+ "Example (cascading select \u2014 country changes trigger city loading):"
1448
+ );
1449
+ lines.push(
1450
+ ` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`
1451
+ );
1452
+ lines.push("");
1453
+ lines.push(
1454
+ "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values)."
1455
+ );
1456
+ lines.push(
1457
+ "IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render."
1458
+ );
1459
+ lines.push("");
1460
+ }
1255
1461
  lines.push("RULES:");
1256
1462
  const baseRules = mode === "chat" ? [
1257
1463
  "When generating UI, wrap all JSONL patches in a ```spec code fence - one JSON object per line inside the fence",