@petrarca/sonnet-core 0.6.0 → 0.8.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.
@@ -39,6 +39,31 @@ interface UIRule {
39
39
  effect: UIRuleEffect;
40
40
  condition: UIRuleCondition;
41
41
  }
42
+ /**
43
+ * A named validator reference carried by `x-ui-validate`. The bare-string form
44
+ * is the no-config common case (`"ibanValid"`); the object form supplies
45
+ * per-reference config and, for async form validators, the inputs that trigger
46
+ * a re-run (`dependsOn`). See the custom-validation design in sonnet-forms.
47
+ */
48
+ interface UIValidateRef {
49
+ /** Registered validator name, resolved against the ValidatorRegistry. */
50
+ name: string;
51
+ /** Per-reference config, passed to the validator as `ctx.args`. */
52
+ args?: Record<string, unknown>;
53
+ /**
54
+ * For an async FORM validator: the field names whose values trigger a re-run.
55
+ * The engine keys/debounces the async result on these values. Ignored for
56
+ * sync validators and for field validators.
57
+ */
58
+ dependsOn?: string[];
59
+ }
60
+ /**
61
+ * The value of `x-ui-validate`: one validator or a list. On a property it
62
+ * selects field validators (`(value, ctx) => ...`); on the root object it
63
+ * selects form validators (`(data, ctx) => FieldError[]`). A bare string is
64
+ * shorthand for `{ name }`.
65
+ */
66
+ type UIValidate = string | UIValidateRef | Array<string | UIValidateRef>;
42
67
  /**
43
68
  * JSON Schema property definition following Draft 2020-12 specification.
44
69
  * Supports basic types, validation constraints, conditionals, and custom extensions.
@@ -88,11 +113,14 @@ type JsonSchemaProperty = {
88
113
  "x-ui-options"?: Record<string, any>;
89
114
  "x-ui-layout"?: UILayoutConfig;
90
115
  "x-ui-title"?: string;
91
- "x-ui-help"?: string;
92
116
  "x-ui-placeholder"?: string;
93
117
  "x-ui-description"?: string;
118
+ "x-ui-validate"?: UIValidate;
94
119
  "x-ui-column"?: {
95
- /** Display order (ascending). Properties without order are appended sorted by key. */
120
+ /**
121
+ * Display order (ascending). Properties without an order are appended
122
+ * after the ordered ones, keeping their schema declaration order.
123
+ */
96
124
  order?: number;
97
125
  /** Exclude this column from the table entirely. */
98
126
  hidden?: boolean;
@@ -129,6 +157,7 @@ type JsonSchema = {
129
157
  $defs?: Record<string, JsonSchemaProperty>;
130
158
  definitions?: Record<string, JsonSchemaProperty>;
131
159
  "x-ui-order"?: string[];
160
+ "x-ui-validate"?: UIValidate;
132
161
  [key: `x-${string}`]: unknown;
133
162
  };
134
163
  /**
@@ -161,10 +190,11 @@ interface UISchema {
161
190
  "x-ui-order"?: string[];
162
191
  /** Declarative show/hide or enable/disable rule (see UIRule). */
163
192
  "x-ui-rule"?: UIRule;
193
+ /** Custom validators by name (override of the schema-level x-ui-validate). */
194
+ "x-ui-validate"?: UIValidate;
164
195
  "x-ui-title"?: string | false;
165
196
  "x-ui-description"?: string;
166
197
  "x-ui-placeholder"?: string;
167
- "x-ui-help"?: string;
168
198
  /** Semantic field-slot width (default "full"). */
169
199
  "x-ui-width"?: WidthToken;
170
200
  /** Column override -- merged over schema-level x-ui-column, UI schema wins. */
@@ -386,7 +416,14 @@ declare function isNullableBoolean(property: JsonSchemaProperty): boolean;
386
416
  declare function extractEnumValues(property: JsonSchemaProperty): (string | number | boolean)[];
387
417
  /** Priority: x-ui-title > title > field name. */
388
418
  declare function getFieldTitle(fieldName: string, property: JsonSchemaProperty): string;
389
- /** Priority: x-ui-description > x-ui-help (legacy alias). */
419
+ /**
420
+ * Help text for a field, rendered under the control.
421
+ *
422
+ * `x-ui-description` overrides the standard JSON Schema `description`. There is
423
+ * one help-text slot per field, so a schema that only carries `description` --
424
+ * anything generated from a typed model, or authored in the designer's Data tab
425
+ * -- shows it without needing a UI hint.
426
+ */
390
427
  declare function getFieldHelpText(property: JsonSchemaProperty): string | undefined;
391
428
  declare function getFieldPlaceholder(property: JsonSchemaProperty): string | undefined;
392
429
  declare function getFieldWidget(property: JsonSchemaProperty): string | undefined;
@@ -531,6 +568,18 @@ declare function hasConditionals(schema: JsonSchema): boolean;
531
568
  * validates that value against a schema. Evaluation yields presentation flags
532
569
  * (visible / enabled) the renderer applies -- the field itself stays declared at
533
570
  * its own position, so layout is stable (unlike if/then/else).
571
+ *
572
+ * Scopes are root-anchored at every depth: a rule on a nested field reads its
573
+ * pointer from the top of the form, so `/country` means the form's `country`
574
+ * even from inside `address`, and a sibling is reached by its full path
575
+ * (`/address/city`). This matches every other JSON Pointer in the vocabulary
576
+ * (`editablePaths`, validation error pointers). The renderer is responsible for
577
+ * passing the root form data in -- see `useRootFormData` in sonnet-forms.
578
+ *
579
+ * A consequence worth knowing when authoring inside `$defs`: a definition is
580
+ * reusable, but a root-anchored scope is not. A rule inside a definition that
581
+ * references `/country` only holds for forms that have a top-level `country`.
582
+ * This is inherent to root-anchoring and is the same in comparable libraries.
534
583
  */
535
584
 
536
585
  /** The presentation state a rule resolves to for its target field. */
@@ -721,9 +770,69 @@ interface ValidationResult {
721
770
  /**
722
771
  * Validate form data against a JSON Schema, recursing into nested objects
723
772
  * and array items.
773
+ *
774
+ * Two layers: this function owns required-ness and array cardinality with
775
+ * human-authored messages; `validateContentConstraints` adds the JSON Schema
776
+ * content constraints (format, pattern, type, length, range, enum, const) via
777
+ * AJV. Both contribute to the same `FieldError[]`, so downstream attribution
778
+ * (per-field `rawErrors`, per-tab error badges) is unchanged.
724
779
  */
725
780
  declare function validateFormData(schema: JsonSchema, data: Record<string, unknown>): ValidationResult;
726
781
 
782
+ /**
783
+ * Content-constraint validation, layered on top of validateFormData.
784
+ *
785
+ * validateFormData covers required-ness and array cardinality with
786
+ * human-authored messages. This module adds the JSON Schema *content*
787
+ * constraints it does not: `format`, `pattern`, `type`, `minLength` /
788
+ * `maxLength`, `minimum` / `maximum` (and their exclusive variants),
789
+ * `enum`, `const`, and `multipleOf`. It reuses the shared AJV instance
790
+ * (already configured with `ajv-formats`) and maps AJV's errors into the
791
+ * same `FieldError` shape the renderer consumes -- AJV's `instancePath` is
792
+ * already an RFC 6901 JSON Pointer, so per-field and per-tab attribution
793
+ * keep working unchanged.
794
+ *
795
+ * Required and array-cardinality keywords are intentionally excluded here so
796
+ * validateFormData stays the single source of those messages -- no field ever
797
+ * shows two errors for the same cause.
798
+ */
799
+
800
+ /**
801
+ * Validate content constraints via AJV and return them as FieldErrors.
802
+ *
803
+ * Only content keywords are returned; required and array-cardinality errors
804
+ * are filtered out (validateFormData owns those). Returns an empty array when
805
+ * the schema compiles cleanly and the data satisfies every content rule, or
806
+ * when AJV cannot compile the schema (in which case the caller's structural
807
+ * validation still stands).
808
+ */
809
+ declare function validateContentConstraints(schema: JsonSchema, data: Record<string, unknown>): FieldError[];
810
+
811
+ /**
812
+ * Merge FieldError lists from several validation sources into one, de-duplicated
813
+ * by field so a field never shows two errors for the same cause.
814
+ *
815
+ * Sources are passed in precedence order (strongest first): structural/content
816
+ * from validateFormData, then custom validators, then external/extra errors.
817
+ * The first source to flag a given field (by its pointer's root segment) wins;
818
+ * later sources add errors only for fields not already flagged. This extends the
819
+ * existing rule inside validateFormData (a content error is skipped on a field
820
+ * that already has a structural error) across every source.
821
+ *
822
+ * Form-level errors (pointer "") are never de-duplicated against fields -- they
823
+ * belong to the form, not a field -- but identical form-level messages collapse.
824
+ *
825
+ * Pure function -- no React, no side effects.
826
+ */
827
+
828
+ /**
829
+ * Merge ordered FieldError sources. Earlier sources take precedence per field.
830
+ *
831
+ * @param sources FieldError lists in precedence order (strongest first).
832
+ * @returns one de-duplicated list preserving each kept error's order.
833
+ */
834
+ declare function mergeFieldErrors(...sources: FieldError[][]): FieldError[];
835
+
727
836
  /**
728
837
  * diffFormData -- structural diff between two form data snapshots.
729
838
  *
@@ -741,4 +850,29 @@ declare function validateFormData(schema: JsonSchema, data: Record<string, unkno
741
850
  */
742
851
  declare function diffFormData(original: Record<string, unknown>, updated: Record<string, unknown>): FormDiff;
743
852
 
744
- export { type ArrayItemChange, type FieldDiff, type FieldError, type FormDiff, type JsonSchema, type JsonSchemaProperty, type RefResolver, type RuleState, type UILayoutConfig, type UILayoutGroupConfig, type UILayoutRowConfig, type UIRule, type UIRuleCondition, type UIRuleEffect, type UISchema, type UISchemaOptions, type ValidationResult, type ValueChange, WIDTH_TOKEN_REMS, type WidthToken, ajv, applySchemaDefaults, diffFormData, evaluateUiRule, extractEnumValues, extractSchemaDefaults, findActiveBranch, generateJsonSchemaFromVariables, getAtPointer, getBranchLabel, getBranchValue, getDiscriminator, getFieldConstraints, getFieldDefault, getFieldHelpText, getFieldPlaceholder, getFieldTitle, getFieldType, getFieldWidget, getOptionalFields, getRequiredFields, getSchemaProperties, hasConditionals, hasProperties, isFieldOptional, isFieldRequired, isNullableBoolean, isOneOf, matchesConst, resolveRefs, resolveSchema, toJsonSchema, validateFormData, validateSchema };
853
+ /**
854
+ * ensureFormItemIds -- stamp a stable `_fid` onto every array-of-object item
855
+ * in a form data snapshot, guided by the schema.
856
+ *
857
+ * The form system matches array items across a diff by their `_fid` (see
858
+ * diffFormData). ArrayWidget assigns one when it renders, but that only reaches
859
+ * the working copy. The dirty-tracking baseline is snapshotted from the
860
+ * incoming `data`, which has none -- so a loaded array diffs as a wholesale
861
+ * replace after a single-item edit.
862
+ *
863
+ * Stamping the incoming data once, before both the working copy and the
864
+ * baseline are derived from it, gives the two sides the *same* ids, so
865
+ * identity survives the diff. Items that already carry a `_fid` keep it.
866
+ *
867
+ * Only array-of-object fields declared in the schema are touched; scalar
868
+ * arrays and everything else pass through unchanged. Recurses into nested
869
+ * objects and into the items of nested arrays.
870
+ */
871
+
872
+ /**
873
+ * Return `data` with a `_fid` on every array-of-object item the schema
874
+ * declares. Returns the input unchanged when nothing needed stamping.
875
+ */
876
+ declare function ensureFormItemIds(data: Record<string, unknown>, schema: JsonSchema): Record<string, unknown>;
877
+
878
+ export { type ArrayItemChange, type FieldDiff, type FieldError, type FormDiff, type JsonSchema, type JsonSchemaProperty, type RefResolver, type RuleState, type UILayoutConfig, type UILayoutGroupConfig, type UILayoutRowConfig, type UIRule, type UIRuleCondition, type UIRuleEffect, type UISchema, type UISchemaOptions, type UIValidate, type UIValidateRef, type ValidationResult, type ValueChange, WIDTH_TOKEN_REMS, type WidthToken, ajv, applySchemaDefaults, diffFormData, ensureFormItemIds, evaluateUiRule, extractEnumValues, extractSchemaDefaults, findActiveBranch, generateJsonSchemaFromVariables, getAtPointer, getBranchLabel, getBranchValue, getDiscriminator, getFieldConstraints, getFieldDefault, getFieldHelpText, getFieldPlaceholder, getFieldTitle, getFieldType, getFieldWidget, getOptionalFields, getRequiredFields, getSchemaProperties, hasConditionals, hasProperties, isFieldOptional, isFieldRequired, isNullableBoolean, isOneOf, matchesConst, mergeFieldErrors, resolveRefs, resolveSchema, toJsonSchema, validateContentConstraints, validateFormData, validateSchema };
@@ -3,6 +3,7 @@ import {
3
3
  } from "../chunk-XAEHYRXC.js";
4
4
  import {
5
5
  FORM_ITEM_ID_FIELD,
6
+ generateId,
6
7
  isEqual
7
8
  } from "../chunk-XCCOEUVC.js";
8
9
 
@@ -57,7 +58,7 @@ function getFieldTitle(fieldName, property) {
57
58
  return property["x-ui-title"] || property.title || fieldName;
58
59
  }
59
60
  function getFieldHelpText(property) {
60
- return property["x-ui-description"] ?? property["x-ui-help"];
61
+ return property["x-ui-description"] ?? property.description;
61
62
  }
62
63
  function getFieldPlaceholder(property) {
63
64
  return property["x-ui-placeholder"];
@@ -146,7 +147,12 @@ var ajv = new Ajv({
146
147
  strict: false,
147
148
  validateFormats: true,
148
149
  // Allow unknown keywords (for x-ui-widget, x-ui-title, etc.)
149
- strictSchema: false
150
+ strictSchema: false,
151
+ // Enable $data references so a keyword can reference another field's value,
152
+ // e.g. { "const": { "$data": "1/password" } } for "must equal password".
153
+ // This is the declarative path for simple cross-field rules (see the
154
+ // custom-validation design); complex cross-field logic uses form validators.
155
+ $data: true
150
156
  });
151
157
  addFormats(ajv);
152
158
  function resolveSchema(schema, formData) {
@@ -466,6 +472,63 @@ function resolveRefs(schema, resolve) {
466
472
  return resolved;
467
473
  }
468
474
 
475
+ // src/schema/contentValidation.ts
476
+ var OWNED_BY_VALIDATE_FORM_DATA = /* @__PURE__ */ new Set([
477
+ "required",
478
+ "minItems",
479
+ "maxItems"
480
+ ]);
481
+ function unescapePointerToken(token) {
482
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
483
+ }
484
+ function labelForPointer(schema, pointer) {
485
+ if (pointer === "") return "";
486
+ const tokens = pointer.split("/").slice(1).map(unescapePointerToken);
487
+ const parts = [];
488
+ let node = schema;
489
+ for (const token of tokens) {
490
+ if (/^\d+$/.test(token)) {
491
+ if (parts.length > 0) {
492
+ parts[parts.length - 1] = `${parts[parts.length - 1]}[${Number(token) + 1}]`;
493
+ }
494
+ node = node?.items;
495
+ continue;
496
+ }
497
+ const props = node?.properties;
498
+ const child = props?.[token];
499
+ parts.push(child ? getFieldTitle(token, child) : token);
500
+ node = child;
501
+ }
502
+ return parts.join(" > ");
503
+ }
504
+ function messageFor(label, keyword, message) {
505
+ const what = label || "Value";
506
+ const detail = message ?? "is invalid";
507
+ return `${what} ${detail}`;
508
+ }
509
+ function validateContentConstraints(schema, data) {
510
+ let validate;
511
+ try {
512
+ validate = ajv.compile(schema);
513
+ } catch {
514
+ return [];
515
+ }
516
+ if (validate(data)) return [];
517
+ const out = [];
518
+ const seen = /* @__PURE__ */ new Set();
519
+ for (const err of validate.errors ?? []) {
520
+ if (OWNED_BY_VALIDATE_FORM_DATA.has(err.keyword)) continue;
521
+ const pointer = err.instancePath;
522
+ const label = labelForPointer(schema, pointer);
523
+ const message = messageFor(label, err.keyword, err.message);
524
+ const key = `${pointer}\0${message}`;
525
+ if (seen.has(key)) continue;
526
+ seen.add(key);
527
+ out.push({ pointer, message });
528
+ }
529
+ return out;
530
+ }
531
+
469
532
  // src/schema/validateFormData.ts
470
533
  function escapePointerToken(token) {
471
534
  return token.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -556,6 +619,11 @@ function validateFormData(schema, data) {
556
619
  { label: "", pointer: "" },
557
620
  fieldErrors
558
621
  );
622
+ const structurallyFlagged = new Set(fieldErrors.map((e) => e.pointer));
623
+ for (const err of validateContentConstraints(schema, data)) {
624
+ if (structurallyFlagged.has(err.pointer)) continue;
625
+ fieldErrors.push(err);
626
+ }
559
627
  return {
560
628
  errors: fieldErrors.map((e) => e.message),
561
629
  fieldErrors,
@@ -563,6 +631,34 @@ function validateFormData(schema, data) {
563
631
  };
564
632
  }
565
633
 
634
+ // src/schema/mergeFieldErrors.ts
635
+ function pointerRoot(pointer) {
636
+ const token = pointer.split("/")[1] ?? "";
637
+ return token.replace(/~1/g, "/").replace(/~0/g, "~");
638
+ }
639
+ function mergeFieldErrors(...sources) {
640
+ const out = [];
641
+ const claimedFields = /* @__PURE__ */ new Set();
642
+ const seenFormMessages = /* @__PURE__ */ new Set();
643
+ for (const source of sources) {
644
+ for (const err of source) {
645
+ if (err.pointer === "") {
646
+ if (seenFormMessages.has(err.message)) continue;
647
+ seenFormMessages.add(err.message);
648
+ out.push(err);
649
+ continue;
650
+ }
651
+ const root = pointerRoot(err.pointer);
652
+ if (claimedFields.has(root)) continue;
653
+ out.push(err);
654
+ }
655
+ for (const err of source) {
656
+ if (err.pointer !== "") claimedFields.add(pointerRoot(err.pointer));
657
+ }
658
+ }
659
+ return out;
660
+ }
661
+
566
662
  // src/schema/diffFormData.ts
567
663
  function areBothPlainObjects(a, b) {
568
664
  return a !== null && b !== null && typeof a === "object" && typeof b === "object" && !Array.isArray(a) && !Array.isArray(b);
@@ -705,11 +801,50 @@ function diffFormData(original, updated) {
705
801
  }
706
802
  return diff;
707
803
  }
804
+
805
+ // src/schema/ensureFormItemIds.ts
806
+ function isPlainObject2(v) {
807
+ return typeof v === "object" && v !== null && !Array.isArray(v);
808
+ }
809
+ function isObjectArray2(property) {
810
+ return property.type === "array" && !!property.items?.properties;
811
+ }
812
+ function stampItem(item, itemSchema) {
813
+ if (!isPlainObject2(item)) return item;
814
+ const withChildren = stampObject(item, itemSchema.properties ?? {});
815
+ return FORM_ITEM_ID_FIELD in withChildren ? withChildren : { ...withChildren, [FORM_ITEM_ID_FIELD]: generateId() };
816
+ }
817
+ function stampValue(value, property) {
818
+ if (isObjectArray2(property) && Array.isArray(value)) {
819
+ return value.map((item) => stampItem(item, property.items));
820
+ }
821
+ if (property.type === "object" && property.properties && isPlainObject2(value)) {
822
+ return stampObject(value, property.properties);
823
+ }
824
+ return value;
825
+ }
826
+ function stampObject(data, properties) {
827
+ let changed = false;
828
+ const out = { ...data };
829
+ for (const [name, property] of Object.entries(properties)) {
830
+ if (!(name in data)) continue;
831
+ const next = stampValue(data[name], property);
832
+ if (next !== data[name]) {
833
+ out[name] = next;
834
+ changed = true;
835
+ }
836
+ }
837
+ return changed ? out : data;
838
+ }
839
+ function ensureFormItemIds(data, schema) {
840
+ return stampObject(data, getSchemaProperties(schema));
841
+ }
708
842
  export {
709
843
  WIDTH_TOKEN_REMS,
710
844
  ajv,
711
845
  applySchemaDefaults,
712
846
  diffFormData,
847
+ ensureFormItemIds,
713
848
  evaluateUiRule,
714
849
  extractEnumValues,
715
850
  extractSchemaDefaults,
@@ -736,9 +871,11 @@ export {
736
871
  isNullableBoolean,
737
872
  isOneOf,
738
873
  matchesConst,
874
+ mergeFieldErrors,
739
875
  resolveRefs,
740
876
  resolveSchema,
741
877
  toJsonSchema,
878
+ validateContentConstraints,
742
879
  validateFormData,
743
880
  validateSchema
744
881
  };