@json-render/core 0.9.0 → 0.10.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.js CHANGED
@@ -889,6 +889,16 @@ function isBindItemExpression(value) {
889
889
  function isCondExpression(value) {
890
890
  return typeof value === "object" && value !== null && "$cond" in value && "$then" in value && "$else" in value;
891
891
  }
892
+ function isComputedExpression(value) {
893
+ return typeof value === "object" && value !== null && "$computed" in value && typeof value.$computed === "string";
894
+ }
895
+ function isTemplateExpression(value) {
896
+ return typeof value === "object" && value !== null && "$template" in value && typeof value.$template === "string";
897
+ }
898
+ var WARNED_COMPUTED_MAX = 100;
899
+ var warnedComputedFns = /* @__PURE__ */ new Set();
900
+ var WARNED_TEMPLATE_MAX = 100;
901
+ var warnedTemplatePaths = /* @__PURE__ */ new Set();
892
902
  function resolveBindItemPath(itemPath, ctx) {
893
903
  if (ctx.repeatBasePath == null) {
894
904
  console.warn(`$bindItem used outside repeat scope: "${itemPath}"`);
@@ -923,6 +933,46 @@ function resolvePropValue(value, ctx) {
923
933
  const result = evaluateVisibility(value.$cond, ctx);
924
934
  return resolvePropValue(result ? value.$then : value.$else, ctx);
925
935
  }
936
+ if (isComputedExpression(value)) {
937
+ const fn = ctx.functions?.[value.$computed];
938
+ if (!fn) {
939
+ if (!warnedComputedFns.has(value.$computed)) {
940
+ if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
941
+ warnedComputedFns.add(value.$computed);
942
+ }
943
+ console.warn(`Unknown $computed function: "${value.$computed}"`);
944
+ }
945
+ return void 0;
946
+ }
947
+ const resolvedArgs = {};
948
+ if (value.args) {
949
+ for (const [key, arg] of Object.entries(value.args)) {
950
+ resolvedArgs[key] = resolvePropValue(arg, ctx);
951
+ }
952
+ }
953
+ return fn(resolvedArgs);
954
+ }
955
+ if (isTemplateExpression(value)) {
956
+ return value.$template.replace(
957
+ /\$\{([^}]+)\}/g,
958
+ (_match, rawPath) => {
959
+ let path = rawPath;
960
+ if (!path.startsWith("/")) {
961
+ if (!warnedTemplatePaths.has(path)) {
962
+ if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
963
+ warnedTemplatePaths.add(path);
964
+ }
965
+ console.warn(
966
+ `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`
967
+ );
968
+ }
969
+ path = "/" + path;
970
+ }
971
+ const resolved = getByPath(ctx.stateModel, path);
972
+ return resolved != null ? String(resolved) : "";
973
+ }
974
+ );
975
+ }
926
976
  if (Array.isArray(value)) {
927
977
  return value.map((item) => resolvePropValue(item, ctx));
928
978
  }
@@ -1087,6 +1137,10 @@ var ValidationConfigSchema = import_zod4.z.object({
1087
1137
  validateOn: import_zod4.z.enum(["change", "blur", "submit"]).optional(),
1088
1138
  enabled: VisibilityConditionSchema.optional()
1089
1139
  });
1140
+ var matchesImpl = (value, args) => {
1141
+ const other = args?.other;
1142
+ return value === other;
1143
+ };
1090
1144
  var builtInValidationFunctions = {
1091
1145
  /**
1092
1146
  * Check if value is not null, undefined, or empty string
@@ -1176,9 +1230,60 @@ var builtInValidationFunctions = {
1176
1230
  /**
1177
1231
  * Check if value matches another field
1178
1232
  */
1179
- matches: (value, args) => {
1233
+ matches: matchesImpl,
1234
+ /**
1235
+ * Alias for matches with a more descriptive name for cross-field equality
1236
+ */
1237
+ equalTo: matchesImpl,
1238
+ /**
1239
+ * Check if value is less than another field's value.
1240
+ * Supports numbers, strings (useful for ISO date comparison), and
1241
+ * cross-type numeric coercion (e.g. string "3" vs number 5).
1242
+ */
1243
+ lessThan: (value, args) => {
1180
1244
  const other = args?.other;
1181
- return value === other;
1245
+ if (value == null || other == null || value === "" || other === "")
1246
+ return false;
1247
+ if (typeof value === "number" && typeof other === "number")
1248
+ return value < other;
1249
+ if (typeof value === "string" && typeof other === "string")
1250
+ return value < other;
1251
+ const numVal = Number(value);
1252
+ const numOther = Number(other);
1253
+ if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
1254
+ return false;
1255
+ },
1256
+ /**
1257
+ * Check if value is greater than another field's value.
1258
+ * Supports numbers, strings (useful for ISO date comparison), and
1259
+ * cross-type numeric coercion (e.g. string "7" vs number 5).
1260
+ */
1261
+ greaterThan: (value, args) => {
1262
+ const other = args?.other;
1263
+ if (value == null || other == null || value === "" || other === "")
1264
+ return false;
1265
+ if (typeof value === "number" && typeof other === "number")
1266
+ return value > other;
1267
+ if (typeof value === "string" && typeof other === "string")
1268
+ return value > other;
1269
+ const numVal = Number(value);
1270
+ const numOther = Number(other);
1271
+ if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
1272
+ return false;
1273
+ },
1274
+ /**
1275
+ * Required only when a condition is met.
1276
+ * Uses JS truthiness: 0, false, "", null, and undefined are all
1277
+ * treated as "condition not met" (field not required), matching
1278
+ * the visibility system's bare-condition semantics.
1279
+ */
1280
+ requiredIf: (value, args) => {
1281
+ const condition = args?.field;
1282
+ if (!condition) return true;
1283
+ if (value === null || value === void 0) return false;
1284
+ if (typeof value === "string") return value.trim().length > 0;
1285
+ if (Array.isArray(value)) return value.length > 0;
1286
+ return true;
1182
1287
  }
1183
1288
  };
1184
1289
  function runValidationCheck(check2, ctx) {
@@ -1186,7 +1291,7 @@ function runValidationCheck(check2, ctx) {
1186
1291
  const resolvedArgs = {};
1187
1292
  if (check2.args) {
1188
1293
  for (const [key, argValue] of Object.entries(check2.args)) {
1189
- resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
1294
+ resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
1190
1295
  }
1191
1296
  }
1192
1297
  const validationFn = builtInValidationFunctions[check2.type] ?? customFunctions?.[check2.type];
@@ -1270,10 +1375,34 @@ var check = {
1270
1375
  type: "url",
1271
1376
  message
1272
1377
  }),
1378
+ numeric: (message = "Must be a number") => ({
1379
+ type: "numeric",
1380
+ message
1381
+ }),
1273
1382
  matches: (otherPath, message = "Fields must match") => ({
1274
1383
  type: "matches",
1275
1384
  args: { other: { $state: otherPath } },
1276
1385
  message
1386
+ }),
1387
+ equalTo: (otherPath, message = "Fields must match") => ({
1388
+ type: "equalTo",
1389
+ args: { other: { $state: otherPath } },
1390
+ message
1391
+ }),
1392
+ lessThan: (otherPath, message) => ({
1393
+ type: "lessThan",
1394
+ args: { other: { $state: otherPath } },
1395
+ message: message ?? "Must be less than the compared field"
1396
+ }),
1397
+ greaterThan: (otherPath, message) => ({
1398
+ type: "greaterThan",
1399
+ args: { other: { $state: otherPath } },
1400
+ message: message ?? "Must be greater than the compared field"
1401
+ }),
1402
+ requiredIf: (fieldPath, message = "This field is required") => ({
1403
+ type: "requiredIf",
1404
+ args: { field: { $state: fieldPath } },
1405
+ message
1277
1406
  })
1278
1407
  };
1279
1408
 
@@ -1342,6 +1471,14 @@ function validateSpec(spec, options = {}) {
1342
1471
  code: "repeat_in_props"
1343
1472
  });
1344
1473
  }
1474
+ if (props && "watch" in props && props.watch !== void 0) {
1475
+ issues.push({
1476
+ severity: "error",
1477
+ message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
1478
+ elementKey: key,
1479
+ code: "watch_in_props"
1480
+ });
1481
+ }
1345
1482
  }
1346
1483
  if (checkOrphans) {
1347
1484
  const reachable = /* @__PURE__ */ new Set();
@@ -1409,6 +1546,16 @@ function autoFixSpec(spec) {
1409
1546
  };
1410
1547
  fixes.push(`Moved "repeat" from props to element level on "${key}".`);
1411
1548
  }
1549
+ currentProps = fixed.props;
1550
+ if (currentProps && "watch" in currentProps && currentProps.watch !== void 0) {
1551
+ const { watch, ...restProps } = currentProps;
1552
+ fixed = {
1553
+ ...fixed,
1554
+ props: restProps,
1555
+ watch
1556
+ };
1557
+ fixes.push(`Moved "watch" from props to element level on "${key}".`);
1558
+ }
1412
1559
  fixedElements[key] = fixed;
1413
1560
  }
1414
1561
  return {
@@ -1894,6 +2041,29 @@ Note: state patches appear right after the elements that use them, so the UI fil
1894
2041
  "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."
1895
2042
  );
1896
2043
  lines.push("");
2044
+ lines.push(
2045
+ '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.'
2046
+ );
2047
+ lines.push(
2048
+ ' 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.'
2049
+ );
2050
+ lines.push("");
2051
+ const catalogFunctions = catalog.data.functions;
2052
+ if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
2053
+ lines.push(
2054
+ '5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.'
2055
+ );
2056
+ lines.push(
2057
+ ' Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`'
2058
+ );
2059
+ lines.push(" Available functions:");
2060
+ for (const name of Object.keys(
2061
+ catalogFunctions
2062
+ )) {
2063
+ lines.push(` - ${name}`);
2064
+ }
2065
+ lines.push("");
2066
+ }
1897
2067
  const hasChecksComponents = allComponents ? Object.entries(allComponents).some(([, def]) => {
1898
2068
  if (!def.props) return false;
1899
2069
  const formatted = formatZodType(def.props);
@@ -1919,7 +2089,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
1919
2089
  lines.push(" - numeric \u2014 value must be a number");
1920
2090
  lines.push(" - url \u2014 valid URL format");
1921
2091
  lines.push(
1922
- ' - matches \u2014 must equal another field (args: { "other": "value" })'
2092
+ ' - matches \u2014 must equal another field (args: { "other": { "$state": "/path" } })'
2093
+ );
2094
+ lines.push(
2095
+ ' - equalTo \u2014 alias for matches (args: { "other": { "$state": "/path" } })'
2096
+ );
2097
+ lines.push(
2098
+ ' - lessThan \u2014 value must be less than another field (args: { "other": { "$state": "/path" } })'
2099
+ );
2100
+ lines.push(
2101
+ ' - greaterThan \u2014 value must be greater than another field (args: { "other": { "$state": "/path" } })'
2102
+ );
2103
+ lines.push(
2104
+ ' - requiredIf \u2014 required only when another field is truthy (args: { "field": { "$state": "/path" } })'
1923
2105
  );
1924
2106
  lines.push("");
1925
2107
  lines.push("Example:");
@@ -1935,6 +2117,30 @@ Note: state patches appear right after the elements that use them, so the UI fil
1935
2117
  );
1936
2118
  lines.push("");
1937
2119
  }
2120
+ if (hasCustomActions || hasBuiltInActions) {
2121
+ lines.push("STATE WATCHERS:");
2122
+ lines.push(
2123
+ "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."
2124
+ );
2125
+ lines.push(
2126
+ "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically."
2127
+ );
2128
+ lines.push("");
2129
+ lines.push(
2130
+ "Example (cascading select \u2014 country changes trigger city loading):"
2131
+ );
2132
+ lines.push(
2133
+ ` ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`
2134
+ );
2135
+ lines.push("");
2136
+ lines.push(
2137
+ "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values)."
2138
+ );
2139
+ lines.push(
2140
+ "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."
2141
+ );
2142
+ lines.push("");
2143
+ }
1938
2144
  lines.push("RULES:");
1939
2145
  const baseRules = mode === "chat" ? [
1940
2146
  "When generating UI, wrap all JSONL patches in a ```spec code fence - one JSON object per line inside the fence",