@makehq/forman-schema 1.13.2 → 1.15.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/README.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  Conversion and validation utilities for Forman Schema.
4
4
 
5
+ ## v1.14.0 — advanced field tracking
6
+
7
+ Non-breaking minor release. New surface for working with `advanced: true` Forman fields:
8
+
9
+ - `toJSONSchema(field, options?)` still returns a bare `JSONSchema7` — fully backward-compatible.
10
+ - Fields marked `advanced: true` are now stamped with `x-advanced: true` on the JSON Schema output, and round-trip through `toFormanSchema` (which restores `advanced: true`).
11
+ - New option `excludeAdvancedFields?: boolean` (default `false`). When `true`, advanced sub-fields of a collection are omitted from the schema.
12
+ - New function `toJSONSchemaAdvanced(field, options?)` returns `{ schema: JSONSchema7, skippedPaths?: { advanced?: string[] } }`. Use it to learn which advanced fields were dropped (e.g. to render a "show advanced" toggle). `toJSONSchema` delegates to it internally and returns just `.schema`.
13
+
5
14
  ## Installation
6
15
 
7
16
  ```bash
@@ -33,6 +42,23 @@ const formanField = {
33
42
  const jsonSchema = toJSONSchema(formanField);
34
43
  ```
35
44
 
45
+ Advanced fields (`advanced: true`) are included by default and stamped with `x-advanced: true`. To omit them from the rendered schema, pass `{ excludeAdvancedFields: true }`:
46
+
47
+ ```typescript
48
+ const jsonSchema = toJSONSchema(formanField, { excludeAdvancedFields: true });
49
+ ```
50
+
51
+ If you also need to know **which** advanced fields were dropped (e.g. to render a "show advanced" toggle), use `toJSONSchemaAdvanced`:
52
+
53
+ ```typescript
54
+ import { toJSONSchemaAdvanced } from '@makehq/forman-schema';
55
+
56
+ const { schema, skippedPaths } = toJSONSchemaAdvanced(formanField, { excludeAdvancedFields: true });
57
+ // skippedPaths?.advanced is an array of dot-notation paths like ['wrapper.field', 'wrapper.arr[].nested']
58
+ ```
59
+
60
+ The filter applies to **sub-fields of a collection** — including nested-by-option fields, array-of-collection items, composite expansions (`udtspec`, `udttype`), and cross-domain buffered fields. It does **not** apply to: the top-level field passed in (always converted), or the item type of an array whose `spec` is a single primitive field. To hide an entire array or any other top-level structure, mark the _parent_ field as `advanced: true`.
61
+
36
62
  ### Converting from JSON Schema to Forman Schema
37
63
 
38
64
  ```typescript
@@ -54,6 +80,56 @@ const jsonSchemaField = {
54
80
  const formanSchema = toFormanSchema(jsonSchemaField);
55
81
  ```
56
82
 
83
+ ### JSON fields (`type: 'json'`)
84
+
85
+ A `json` field can carry an explicit `schema` (a JSON Schema). This lets you author complex parts of a form directly in JSON Schema and mix them with primitive Forman fields:
86
+
87
+ ```typescript
88
+ const formanField = {
89
+ type: 'collection',
90
+ spec: [
91
+ { name: 'title', type: 'text' },
92
+ {
93
+ name: 'input',
94
+ type: 'json',
95
+ schema: {
96
+ type: 'object',
97
+ properties: {
98
+ name: { type: 'string' },
99
+ age: { type: 'number' },
100
+ },
101
+ },
102
+ },
103
+ ],
104
+ };
105
+ ```
106
+
107
+ On conversion, the `schema` is **echoed verbatim** into the JSON Schema output (the field's `label`/`help` fill in `title`/`description` only when the schema omits them). An enumerable `x-json` marker is added so `toFormanSchema` can recover the `json` type; it survives JSON serialization. A `json` field **without** a `schema` renders as a plain object schema (`{ type: 'object' }`), since a JSON value is most naturally an object.
108
+
109
+ #### External validators
110
+
111
+ The library cannot validate a JSON value against an arbitrary JSON Schema on its own — it has **no JSON Schema validator built in**. Validation of `json` fields is therefore opt-in: **a `json` value is not validated unless you provide a `validateJson` callback.** Without it, the value passes through untouched.
112
+
113
+ This is the first of a general **external validator** concept: a callback that performs validation the library can't, and returns a `FormanExternalValidationResult` verdict (`{ valid, errors?, warnings? }`) that is spliced into the overall result. The callback may be async (awaited), and its `errors`/`warnings` are stamped with the field's domain and path automatically. A `valid: false` verdict always fails validation, even when it carries no messages.
114
+
115
+ ```typescript
116
+ import { validateForman, type FormanExternalValidationResult } from '@makehq/forman-schema';
117
+ import Ajv from 'ajv'; // any JSON Schema validator works
118
+
119
+ const ajv = new Ajv({ allErrors: true });
120
+
121
+ const result = await validateForman({ input: { name: 'Alice', age: 30 } }, schema, {
122
+ async validateJson(schema, value): Promise<FormanExternalValidationResult> {
123
+ const validate = ajv.compile(schema);
124
+ if (validate(value)) return { valid: true };
125
+ return {
126
+ valid: false,
127
+ errors: (validate.errors ?? []).map(e => `${e.instancePath} ${e.message}`),
128
+ };
129
+ },
130
+ });
131
+ ```
132
+
57
133
  ### Validation
58
134
 
59
135
  Validate Forman values against a Forman Schema. Two entry points are available:
@@ -191,7 +267,7 @@ const result = await validateFormanWithDomains(
191
267
  - hidden → string
192
268
  - hook → number
193
269
  - integer → number
194
- - json → string
270
+ - json → object (or its `schema` echoed verbatim when provided — see [JSON fields](#json-fields-type-json))
195
271
  - keychain → number
196
272
  - number → number
197
273
  - path → string
package/dist/index.cjs CHANGED
@@ -22,6 +22,7 @@ var index_exports = {};
22
22
  __export(index_exports, {
23
23
  toFormanSchema: () => toFormanSchema,
24
24
  toJSONSchema: () => toJSONSchema,
25
+ toJSONSchemaAdvanced: () => toJSONSchemaAdvanced,
25
26
  validateForman: () => validateForman,
26
27
  validateFormanWithDomains: () => validateFormanWithDomains
27
28
  });
@@ -560,7 +561,7 @@ var FORMAN_TYPE_MAP = {
560
561
  boolean: "boolean",
561
562
  checkbox: "boolean",
562
563
  date: "string",
563
- json: "string",
564
+ json: "object",
564
565
  buffer: "string",
565
566
  cert: "string",
566
567
  color: "string",
@@ -607,13 +608,15 @@ function appendQueryString(path, domain, tail) {
607
608
  const separator = path.includes("?") ? "&" : "?";
608
609
  return `${path}${separator}${queryString}`;
609
610
  }
610
- function createDefaultContext() {
611
+ function createDefaultContext(options) {
611
612
  return {
612
613
  domain: "default",
613
614
  tail: [],
614
615
  path: [],
615
616
  roots: {},
616
617
  definitions: {},
618
+ excludeAdvancedFields: options?.excludeAdvancedFields ?? false,
619
+ skippedPaths: {},
617
620
  addConditionalFields: () => {
618
621
  throw new SchemaConversionError("Cannot serialize nested fields without parent field.");
619
622
  }
@@ -668,6 +671,8 @@ function toJSONSchemaInternal(field, context) {
668
671
  case "collection":
669
672
  case "dynamicCollection":
670
673
  return handleCollectionType(normalizedField, result, context);
674
+ case "json":
675
+ return handleJsonType(normalizedField, result);
671
676
  case "array":
672
677
  case "filestorage":
673
678
  return handleArrayType(normalizedField, result, context);
@@ -695,6 +700,7 @@ function handleCollectionType(field, result, context) {
695
700
  properties: {},
696
701
  required: []
697
702
  });
703
+ const collectionPath = field.name ? [...context.path, field.name] : context.path;
698
704
  function addField(subField, tail) {
699
705
  if (typeof subField === "string") {
700
706
  const value = { $ref: appendQueryString(subField, context.domain, tail || context.tail) };
@@ -707,28 +713,41 @@ function handleCollectionType(field, result, context) {
707
713
  }
708
714
  if (!subField.name) return;
709
715
  if (result.properties && Object.hasOwn(result.properties, subField.name)) return;
716
+ if (subField.advanced === true && context.excludeAdvancedFields) {
717
+ (context.skippedPaths.advanced ||= []).push([...collectionPath, subField.name].join("."));
718
+ return;
719
+ }
710
720
  if (subField.required) {
711
721
  result.required.push(subField.name);
712
722
  }
723
+ const subSchema = toJSONSchemaInternal(subField, {
724
+ ...context,
725
+ domain: field["x-domain-root"] || context.domain,
726
+ tail: tail || context.tail,
727
+ path: collectionPath,
728
+ addConditionalFields: (name, value, nested) => {
729
+ result.allOf ||= [];
730
+ result.allOf.push({
731
+ if: {
732
+ properties: {
733
+ [name]: { const: value }
734
+ }
735
+ },
736
+ then: typeof nested === "string" ? { $ref: nested } : nested
737
+ });
738
+ }
739
+ });
740
+ if (subField.advanced) {
741
+ Object.defineProperty(subSchema, "x-advanced", {
742
+ configurable: true,
743
+ enumerable: true,
744
+ writable: true,
745
+ value: true
746
+ });
747
+ }
713
748
  Object.defineProperty(result.properties, subField.name, {
714
749
  enumerable: true,
715
- value: toJSONSchemaInternal(subField, {
716
- ...context,
717
- domain: field["x-domain-root"] || context.domain,
718
- tail: tail || context.tail,
719
- path: [...context.path, field.name],
720
- addConditionalFields: (name, value, nested) => {
721
- result.allOf ||= [];
722
- result.allOf.push({
723
- if: {
724
- properties: {
725
- [name]: { const: value }
726
- }
727
- },
728
- then: typeof nested === "string" ? { $ref: nested } : nested
729
- });
730
- }
731
- })
750
+ value: subSchema
732
751
  });
733
752
  }
734
753
  if (field["x-domain-root"]) {
@@ -1010,6 +1029,20 @@ function handleSelectOrPathType(field, result, context) {
1010
1029
  if (field.rpc) result = processRpcDirective(field, result, context);
1011
1030
  return result;
1012
1031
  }
1032
+ function handleJsonType(field, result) {
1033
+ if (!isObject(field.schema)) {
1034
+ return result;
1035
+ }
1036
+ delete result.type;
1037
+ Object.assign(result, field.schema);
1038
+ Object.defineProperty(result, "x-json", {
1039
+ configurable: true,
1040
+ enumerable: true,
1041
+ writable: true,
1042
+ value: true
1043
+ });
1044
+ return result;
1045
+ }
1013
1046
  function handlePrimitiveType(field, result, context) {
1014
1047
  if (field.default !== "" && field.default != null) {
1015
1048
  result.default = field.default;
@@ -1129,7 +1162,8 @@ var SCHEMA_STRIP_KEYS = [
1129
1162
  "tags",
1130
1163
  "extension",
1131
1164
  "codepage",
1132
- "logic"
1165
+ "logic",
1166
+ "schema"
1133
1167
  ];
1134
1168
  function clampFieldForSchema(field) {
1135
1169
  const clamped = { ...field };
@@ -1174,7 +1208,7 @@ var FORMAN_TYPE_MAP2 = {
1174
1208
  boolean: "boolean",
1175
1209
  checkbox: "boolean",
1176
1210
  date: "string",
1177
- json: "string",
1211
+ json: void 0,
1178
1212
  buffer: "string",
1179
1213
  cert: "string",
1180
1214
  color: "string",
@@ -1269,7 +1303,8 @@ async function validateFormanWithDomainsInternal(domains, options) {
1269
1303
  ...data,
1270
1304
  ...localData
1271
1305
  });
1272
- }
1306
+ },
1307
+ validateJson: options?.validateJson
1273
1308
  }
1274
1309
  );
1275
1310
  errors.push(...result.errors);
@@ -1401,6 +1436,8 @@ async function validateFormanValue(value, field, context) {
1401
1436
  return validateFormanValue(value, udtspecExpand({ ...normalizedField }), context);
1402
1437
  }
1403
1438
  switch (normalizedField.type) {
1439
+ case "json":
1440
+ return handleJsonType2(value, normalizedField, context);
1404
1441
  case "collection":
1405
1442
  return handleCollectionType2(value, normalizedField, context);
1406
1443
  case "array":
@@ -2001,6 +2038,35 @@ async function handleNestedFields(nested, value, field, context) {
2001
2038
  warnings
2002
2039
  };
2003
2040
  }
2041
+ async function handleJsonType2(value, field, context) {
2042
+ const errors = [];
2043
+ const warnings = [];
2044
+ if (!isObject(field.schema) || !context.validateJson) {
2045
+ return { valid: true, errors, warnings };
2046
+ }
2047
+ const path = context.path.join(".");
2048
+ let fragment;
2049
+ try {
2050
+ fragment = await context.validateJson(field.schema, value);
2051
+ } catch (err) {
2052
+ const message = err instanceof Error ? err.message : "JSON schema validation failed unexpectedly.";
2053
+ return { valid: false, errors: [{ domain: context.domain, path, message }], warnings };
2054
+ }
2055
+ for (const message of fragment.errors ?? []) {
2056
+ errors.push({ domain: context.domain, path, message });
2057
+ }
2058
+ for (const message of fragment.warnings ?? []) {
2059
+ warnings.push({ domain: context.domain, path, message });
2060
+ }
2061
+ if (fragment.valid === false && errors.length === 0) {
2062
+ errors.push({ domain: context.domain, path, message: "JSON value failed schema validation." });
2063
+ }
2064
+ return {
2065
+ valid: errors.length === 0,
2066
+ errors,
2067
+ warnings
2068
+ };
2069
+ }
2004
2070
  async function handlePrimitiveType2(value, field, context) {
2005
2071
  const errors = [];
2006
2072
  const warnings = [];
@@ -2074,6 +2140,18 @@ var JSON_PRIMITIVE_TYPE_MAP = {
2074
2140
  boolean: "boolean"
2075
2141
  };
2076
2142
  function toFormanSchema(field) {
2143
+ const result = toFormanSchemaInternal(field);
2144
+ if (Object.getOwnPropertyDescriptor(field, "x-advanced")?.value === true) {
2145
+ result.advanced = true;
2146
+ }
2147
+ return result;
2148
+ }
2149
+ function toFormanSchemaInternal(field) {
2150
+ if (Object.getOwnPropertyDescriptor(field, "x-json")?.value === true) {
2151
+ const schema = { ...field };
2152
+ delete schema["x-json"];
2153
+ return { type: "json", schema };
2154
+ }
2077
2155
  const compositeType = Object.getOwnPropertyDescriptor(field, "x-composite")?.value;
2078
2156
  if (compositeType === "udttype") return udttypeCollapse(field);
2079
2157
  if (compositeType === "udtspec") return udtspecCollapse(field);
@@ -2209,18 +2287,28 @@ function handleSearchDirective(formanField, directive) {
2209
2287
  }
2210
2288
 
2211
2289
  // src/index.ts
2212
- function toJSONSchema(field) {
2213
- const context = createDefaultContext();
2214
- const result = toJSONSchemaInternal(field, context);
2290
+ function toJSONSchemaAdvanced(field, options) {
2291
+ const context = createDefaultContext(options);
2292
+ const schema = toJSONSchemaInternal(field, context);
2215
2293
  if (Object.keys(context.definitions ?? {}).length > 0) {
2216
- Object.defineProperty(result, "definitions", {
2294
+ Object.defineProperty(schema, "definitions", {
2217
2295
  configurable: true,
2218
2296
  enumerable: true,
2219
2297
  writable: true,
2220
2298
  value: context.definitions
2221
2299
  });
2222
2300
  }
2223
- return result;
2301
+ const skippedPaths = {};
2302
+ if (context.skippedPaths.advanced?.length) {
2303
+ skippedPaths.advanced = context.skippedPaths.advanced;
2304
+ }
2305
+ return {
2306
+ schema,
2307
+ ...Object.keys(skippedPaths).length > 0 ? { skippedPaths } : {}
2308
+ };
2309
+ }
2310
+ function toJSONSchema(field, options) {
2311
+ return toJSONSchemaAdvanced(field, options).schema;
2224
2312
  }
2225
2313
  function validateFormanWithDomains(domains, options) {
2226
2314
  return validateFormanWithDomainsInternal(domains, options);
@@ -2235,6 +2323,7 @@ function validateForman(values, schema, options, restoreExtras) {
2235
2323
  0 && (module.exports = {
2236
2324
  toFormanSchema,
2237
2325
  toJSONSchema,
2326
+ toJSONSchemaAdvanced,
2238
2327
  validateForman,
2239
2328
  validateFormanWithDomains
2240
2329
  });
package/dist/index.d.cts CHANGED
@@ -41,6 +41,8 @@ type FormanSchemaField = {
41
41
  help?: string;
42
42
  /** Sub-fields specification for collection or array types */
43
43
  spec?: FormanSchemaField[] | FormanSchemaField;
44
+ /** JSON Schema for `json` typed fields */
45
+ schema?: JSONSchema7;
44
46
  /** Hide field behind advanced toggle */
45
47
  advanced?: boolean;
46
48
  /** Human readable label for the field */
@@ -222,6 +224,43 @@ type FormanSchemaFieldState = {
222
224
  nested?: Record<string, FormanSchemaFieldState>;
223
225
  items?: Record<string, FormanSchemaFieldState>[];
224
226
  };
227
+ /**
228
+ * Options for converting a Forman Schema to JSON Schema
229
+ */
230
+ type FormanJsonSchemaOptions = {
231
+ /**
232
+ * Exclude fields marked `advanced: true` from the rendered schema. Defaults to `false`
233
+ * (advanced fields are included and stamped with `x-advanced: true`). When `true`,
234
+ * advanced fields are omitted; their dot-notation paths are reported on
235
+ * `toJSONSchemaAdvanced`'s `skippedPaths.advanced` so the caller can re-request them.
236
+ */
237
+ excludeAdvancedFields?: boolean;
238
+ };
239
+ /**
240
+ * Result of converting a Forman Schema to JSON Schema
241
+ */
242
+ type FormanJsonSchemaResult = {
243
+ /** The converted JSON Schema */
244
+ schema: JSONSchema7;
245
+ /** Paths to fields that were skipped during conversion. Present only when at least one field was skipped. */
246
+ skippedPaths?: {
247
+ /** Dot-notation paths of advanced fields that were skipped. Present only when at least one advanced field was skipped. */
248
+ advanced?: string[];
249
+ };
250
+ };
251
+ /**
252
+ * Verdict fragment returned by an external validation callback (e.g. `validateJson`) that the
253
+ * library cannot perform itself. Spliced into the overall validation result: `errors`/`warnings`
254
+ * are stamped with the field's domain and path.
255
+ */
256
+ type FormanExternalValidationResult = {
257
+ /** Whether the value is valid. A `false` verdict always fails validation, even with no messages. */
258
+ valid: boolean;
259
+ /** Error messages (cause validation to fail) */
260
+ errors?: string[];
261
+ /** Warning messages (do not affect validity) */
262
+ warnings?: string[];
263
+ };
225
264
  type FormanValidationOptions = {
226
265
  /** Unknown fields are not allowed when strict is true */
227
266
  strict?: boolean;
@@ -231,6 +270,10 @@ type FormanValidationOptions = {
231
270
  schemas?: boolean;
232
271
  /** Remote resource resolver */
233
272
  resolveRemote?(path: string, data: Record<string, unknown>): Promise<unknown>;
273
+ /** Validator for `json` typed fields. Receives the field's JSON Schema and the value, and
274
+ * returns (or resolves to) a result fragment that is spliced into the overall validation
275
+ * result. When omitted, `json` fields with a `schema` pass without schema validation. */
276
+ validateJson?(schema: JSONSchema7, value: unknown): FormanExternalValidationResult | Promise<FormanExternalValidationResult>;
234
277
  /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */
235
278
  domainAliases?: Record<string, string>;
236
279
  /** Whether to allow dynamic values (IML expressions, unresolved RPC options).
@@ -246,12 +289,43 @@ type FormanValidationOptions = {
246
289
  */
247
290
  declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
248
291
 
292
+ /**
293
+ * Converts a Forman Schema field to its JSON Schema equivalent and reports the paths of any
294
+ * fields that were skipped during conversion.
295
+ *
296
+ * **Advanced fields** (`advanced: true`) are included by default and stamped with
297
+ * `x-advanced: true` on the JSON Schema (the marker round-trips through `toFormanSchema`).
298
+ * Pass `{ excludeAdvancedFields: true }` to omit them — when excluded, the affected
299
+ * dot-notation paths are reported on `skippedPaths.advanced`. The filter applies to sub-fields
300
+ * of a collection (main form, nested-by-option, array-of-collection items, composite
301
+ * expansions, cross-domain buffered fields). It does NOT apply to the top-level field passed
302
+ * here, nor to the item type of an array whose `spec` is a single primitive field. Mark the
303
+ * parent as `advanced: true` to hide such structures.
304
+ *
305
+ * Known limitation: composite types (`udtspec`, `udttype`) are memoized in
306
+ * `definitions[type]`; advanced fields inside a composite template are recorded with the
307
+ * path of the FIRST usage only. See the comment near `compositeHandlers` in `src/forman.ts`.
308
+ *
309
+ * If you don't need `skippedPaths`, use {@link toJSONSchema} which returns just the schema.
310
+ *
311
+ * @param field The Forman Schema field to convert
312
+ * @param options Conversion options
313
+ * @returns The conversion result `{ schema, skippedPaths? }`. `skippedPaths` is omitted when nothing was skipped.
314
+ */
315
+ declare function toJSONSchemaAdvanced(field: FormanSchemaField, options?: FormanJsonSchemaOptions): FormanJsonSchemaResult;
249
316
  /**
250
317
  * Converts a Forman Schema field to its JSON Schema equivalent.
318
+ *
319
+ * Advanced fields (`advanced: true`) are included by default and stamped with `x-advanced: true`.
320
+ * Pass `{ excludeAdvancedFields: true }` to omit them. If you need to know *which* advanced
321
+ * fields were dropped (e.g. to render a "show advanced" toggle), use {@link toJSONSchemaAdvanced}
322
+ * which returns `{ schema, skippedPaths? }`.
323
+ *
251
324
  * @param field The Forman Schema field to convert
252
- * @returns The equivalent JSON Schema field
325
+ * @param options Conversion options
326
+ * @returns The equivalent JSON Schema
253
327
  */
254
- declare function toJSONSchema(field: FormanSchemaField): JSONSchema7;
328
+ declare function toJSONSchema(field: FormanSchemaField, options?: FormanJsonSchemaOptions): JSONSchema7;
255
329
  /**
256
330
  * Validates a Forman domains against schemas
257
331
  * @param domains The domains to validate
@@ -279,4 +353,4 @@ declare function validateFormanWithDomains(domains: Record<string, {
279
353
  */
280
354
  declare function validateForman(values: Record<string, unknown>, schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record<string, Record<string, unknown>>): Promise<FormanValidationResult>;
281
355
 
282
- export { type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, validateForman, validateFormanWithDomains };
356
+ export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
package/dist/index.d.ts CHANGED
@@ -41,6 +41,8 @@ type FormanSchemaField = {
41
41
  help?: string;
42
42
  /** Sub-fields specification for collection or array types */
43
43
  spec?: FormanSchemaField[] | FormanSchemaField;
44
+ /** JSON Schema for `json` typed fields */
45
+ schema?: JSONSchema7;
44
46
  /** Hide field behind advanced toggle */
45
47
  advanced?: boolean;
46
48
  /** Human readable label for the field */
@@ -222,6 +224,43 @@ type FormanSchemaFieldState = {
222
224
  nested?: Record<string, FormanSchemaFieldState>;
223
225
  items?: Record<string, FormanSchemaFieldState>[];
224
226
  };
227
+ /**
228
+ * Options for converting a Forman Schema to JSON Schema
229
+ */
230
+ type FormanJsonSchemaOptions = {
231
+ /**
232
+ * Exclude fields marked `advanced: true` from the rendered schema. Defaults to `false`
233
+ * (advanced fields are included and stamped with `x-advanced: true`). When `true`,
234
+ * advanced fields are omitted; their dot-notation paths are reported on
235
+ * `toJSONSchemaAdvanced`'s `skippedPaths.advanced` so the caller can re-request them.
236
+ */
237
+ excludeAdvancedFields?: boolean;
238
+ };
239
+ /**
240
+ * Result of converting a Forman Schema to JSON Schema
241
+ */
242
+ type FormanJsonSchemaResult = {
243
+ /** The converted JSON Schema */
244
+ schema: JSONSchema7;
245
+ /** Paths to fields that were skipped during conversion. Present only when at least one field was skipped. */
246
+ skippedPaths?: {
247
+ /** Dot-notation paths of advanced fields that were skipped. Present only when at least one advanced field was skipped. */
248
+ advanced?: string[];
249
+ };
250
+ };
251
+ /**
252
+ * Verdict fragment returned by an external validation callback (e.g. `validateJson`) that the
253
+ * library cannot perform itself. Spliced into the overall validation result: `errors`/`warnings`
254
+ * are stamped with the field's domain and path.
255
+ */
256
+ type FormanExternalValidationResult = {
257
+ /** Whether the value is valid. A `false` verdict always fails validation, even with no messages. */
258
+ valid: boolean;
259
+ /** Error messages (cause validation to fail) */
260
+ errors?: string[];
261
+ /** Warning messages (do not affect validity) */
262
+ warnings?: string[];
263
+ };
225
264
  type FormanValidationOptions = {
226
265
  /** Unknown fields are not allowed when strict is true */
227
266
  strict?: boolean;
@@ -231,6 +270,10 @@ type FormanValidationOptions = {
231
270
  schemas?: boolean;
232
271
  /** Remote resource resolver */
233
272
  resolveRemote?(path: string, data: Record<string, unknown>): Promise<unknown>;
273
+ /** Validator for `json` typed fields. Receives the field's JSON Schema and the value, and
274
+ * returns (or resolves to) a result fragment that is spliced into the overall validation
275
+ * result. When omitted, `json` fields with a `schema` pass without schema validation. */
276
+ validateJson?(schema: JSONSchema7, value: unknown): FormanExternalValidationResult | Promise<FormanExternalValidationResult>;
234
277
  /** Maps domain names used in nested.domain to actual domain keys passed to validateFormanWithDomains */
235
278
  domainAliases?: Record<string, string>;
236
279
  /** Whether to allow dynamic values (IML expressions, unresolved RPC options).
@@ -246,12 +289,43 @@ type FormanValidationOptions = {
246
289
  */
247
290
  declare function toFormanSchema(field: JSONSchema7): FormanSchemaField;
248
291
 
292
+ /**
293
+ * Converts a Forman Schema field to its JSON Schema equivalent and reports the paths of any
294
+ * fields that were skipped during conversion.
295
+ *
296
+ * **Advanced fields** (`advanced: true`) are included by default and stamped with
297
+ * `x-advanced: true` on the JSON Schema (the marker round-trips through `toFormanSchema`).
298
+ * Pass `{ excludeAdvancedFields: true }` to omit them — when excluded, the affected
299
+ * dot-notation paths are reported on `skippedPaths.advanced`. The filter applies to sub-fields
300
+ * of a collection (main form, nested-by-option, array-of-collection items, composite
301
+ * expansions, cross-domain buffered fields). It does NOT apply to the top-level field passed
302
+ * here, nor to the item type of an array whose `spec` is a single primitive field. Mark the
303
+ * parent as `advanced: true` to hide such structures.
304
+ *
305
+ * Known limitation: composite types (`udtspec`, `udttype`) are memoized in
306
+ * `definitions[type]`; advanced fields inside a composite template are recorded with the
307
+ * path of the FIRST usage only. See the comment near `compositeHandlers` in `src/forman.ts`.
308
+ *
309
+ * If you don't need `skippedPaths`, use {@link toJSONSchema} which returns just the schema.
310
+ *
311
+ * @param field The Forman Schema field to convert
312
+ * @param options Conversion options
313
+ * @returns The conversion result `{ schema, skippedPaths? }`. `skippedPaths` is omitted when nothing was skipped.
314
+ */
315
+ declare function toJSONSchemaAdvanced(field: FormanSchemaField, options?: FormanJsonSchemaOptions): FormanJsonSchemaResult;
249
316
  /**
250
317
  * Converts a Forman Schema field to its JSON Schema equivalent.
318
+ *
319
+ * Advanced fields (`advanced: true`) are included by default and stamped with `x-advanced: true`.
320
+ * Pass `{ excludeAdvancedFields: true }` to omit them. If you need to know *which* advanced
321
+ * fields were dropped (e.g. to render a "show advanced" toggle), use {@link toJSONSchemaAdvanced}
322
+ * which returns `{ schema, skippedPaths? }`.
323
+ *
251
324
  * @param field The Forman Schema field to convert
252
- * @returns The equivalent JSON Schema field
325
+ * @param options Conversion options
326
+ * @returns The equivalent JSON Schema
253
327
  */
254
- declare function toJSONSchema(field: FormanSchemaField): JSONSchema7;
328
+ declare function toJSONSchema(field: FormanSchemaField, options?: FormanJsonSchemaOptions): JSONSchema7;
255
329
  /**
256
330
  * Validates a Forman domains against schemas
257
331
  * @param domains The domains to validate
@@ -279,4 +353,4 @@ declare function validateFormanWithDomains(domains: Record<string, {
279
353
  */
280
354
  declare function validateForman(values: Record<string, unknown>, schema: FormanSchemaField[], options?: FormanValidationOptions, restoreExtras?: Record<string, Record<string, unknown>>): Promise<FormanValidationResult>;
281
355
 
282
- export { type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, validateForman, validateFormanWithDomains };
356
+ export { type FormanExternalValidationResult, type FormanJsonSchemaOptions, type FormanJsonSchemaResult, type FormanSchemaDirectoryOption, type FormanSchemaExtendedNested, type FormanSchemaExtendedOptions, type FormanSchemaField, type FormanSchemaFieldType, type FormanSchemaNested, type FormanSchemaOption, type FormanSchemaOptionGroup, type FormanSchemaPathExtendedOptions, type FormanSchemaRPCButton, type FormanSchemaValue, type FormanValidationOptions, type FormanValidationResult, toFormanSchema, toJSONSchema, toJSONSchemaAdvanced, validateForman, validateFormanWithDomains };
package/dist/index.js CHANGED
@@ -531,7 +531,7 @@ var FORMAN_TYPE_MAP = {
531
531
  boolean: "boolean",
532
532
  checkbox: "boolean",
533
533
  date: "string",
534
- json: "string",
534
+ json: "object",
535
535
  buffer: "string",
536
536
  cert: "string",
537
537
  color: "string",
@@ -578,13 +578,15 @@ function appendQueryString(path, domain, tail) {
578
578
  const separator = path.includes("?") ? "&" : "?";
579
579
  return `${path}${separator}${queryString}`;
580
580
  }
581
- function createDefaultContext() {
581
+ function createDefaultContext(options) {
582
582
  return {
583
583
  domain: "default",
584
584
  tail: [],
585
585
  path: [],
586
586
  roots: {},
587
587
  definitions: {},
588
+ excludeAdvancedFields: options?.excludeAdvancedFields ?? false,
589
+ skippedPaths: {},
588
590
  addConditionalFields: () => {
589
591
  throw new SchemaConversionError("Cannot serialize nested fields without parent field.");
590
592
  }
@@ -639,6 +641,8 @@ function toJSONSchemaInternal(field, context) {
639
641
  case "collection":
640
642
  case "dynamicCollection":
641
643
  return handleCollectionType(normalizedField, result, context);
644
+ case "json":
645
+ return handleJsonType(normalizedField, result);
642
646
  case "array":
643
647
  case "filestorage":
644
648
  return handleArrayType(normalizedField, result, context);
@@ -666,6 +670,7 @@ function handleCollectionType(field, result, context) {
666
670
  properties: {},
667
671
  required: []
668
672
  });
673
+ const collectionPath = field.name ? [...context.path, field.name] : context.path;
669
674
  function addField(subField, tail) {
670
675
  if (typeof subField === "string") {
671
676
  const value = { $ref: appendQueryString(subField, context.domain, tail || context.tail) };
@@ -678,28 +683,41 @@ function handleCollectionType(field, result, context) {
678
683
  }
679
684
  if (!subField.name) return;
680
685
  if (result.properties && Object.hasOwn(result.properties, subField.name)) return;
686
+ if (subField.advanced === true && context.excludeAdvancedFields) {
687
+ (context.skippedPaths.advanced ||= []).push([...collectionPath, subField.name].join("."));
688
+ return;
689
+ }
681
690
  if (subField.required) {
682
691
  result.required.push(subField.name);
683
692
  }
693
+ const subSchema = toJSONSchemaInternal(subField, {
694
+ ...context,
695
+ domain: field["x-domain-root"] || context.domain,
696
+ tail: tail || context.tail,
697
+ path: collectionPath,
698
+ addConditionalFields: (name, value, nested) => {
699
+ result.allOf ||= [];
700
+ result.allOf.push({
701
+ if: {
702
+ properties: {
703
+ [name]: { const: value }
704
+ }
705
+ },
706
+ then: typeof nested === "string" ? { $ref: nested } : nested
707
+ });
708
+ }
709
+ });
710
+ if (subField.advanced) {
711
+ Object.defineProperty(subSchema, "x-advanced", {
712
+ configurable: true,
713
+ enumerable: true,
714
+ writable: true,
715
+ value: true
716
+ });
717
+ }
684
718
  Object.defineProperty(result.properties, subField.name, {
685
719
  enumerable: true,
686
- value: toJSONSchemaInternal(subField, {
687
- ...context,
688
- domain: field["x-domain-root"] || context.domain,
689
- tail: tail || context.tail,
690
- path: [...context.path, field.name],
691
- addConditionalFields: (name, value, nested) => {
692
- result.allOf ||= [];
693
- result.allOf.push({
694
- if: {
695
- properties: {
696
- [name]: { const: value }
697
- }
698
- },
699
- then: typeof nested === "string" ? { $ref: nested } : nested
700
- });
701
- }
702
- })
720
+ value: subSchema
703
721
  });
704
722
  }
705
723
  if (field["x-domain-root"]) {
@@ -981,6 +999,20 @@ function handleSelectOrPathType(field, result, context) {
981
999
  if (field.rpc) result = processRpcDirective(field, result, context);
982
1000
  return result;
983
1001
  }
1002
+ function handleJsonType(field, result) {
1003
+ if (!isObject(field.schema)) {
1004
+ return result;
1005
+ }
1006
+ delete result.type;
1007
+ Object.assign(result, field.schema);
1008
+ Object.defineProperty(result, "x-json", {
1009
+ configurable: true,
1010
+ enumerable: true,
1011
+ writable: true,
1012
+ value: true
1013
+ });
1014
+ return result;
1015
+ }
984
1016
  function handlePrimitiveType(field, result, context) {
985
1017
  if (field.default !== "" && field.default != null) {
986
1018
  result.default = field.default;
@@ -1100,7 +1132,8 @@ var SCHEMA_STRIP_KEYS = [
1100
1132
  "tags",
1101
1133
  "extension",
1102
1134
  "codepage",
1103
- "logic"
1135
+ "logic",
1136
+ "schema"
1104
1137
  ];
1105
1138
  function clampFieldForSchema(field) {
1106
1139
  const clamped = { ...field };
@@ -1145,7 +1178,7 @@ var FORMAN_TYPE_MAP2 = {
1145
1178
  boolean: "boolean",
1146
1179
  checkbox: "boolean",
1147
1180
  date: "string",
1148
- json: "string",
1181
+ json: void 0,
1149
1182
  buffer: "string",
1150
1183
  cert: "string",
1151
1184
  color: "string",
@@ -1240,7 +1273,8 @@ async function validateFormanWithDomainsInternal(domains, options) {
1240
1273
  ...data,
1241
1274
  ...localData
1242
1275
  });
1243
- }
1276
+ },
1277
+ validateJson: options?.validateJson
1244
1278
  }
1245
1279
  );
1246
1280
  errors.push(...result.errors);
@@ -1372,6 +1406,8 @@ async function validateFormanValue(value, field, context) {
1372
1406
  return validateFormanValue(value, udtspecExpand({ ...normalizedField }), context);
1373
1407
  }
1374
1408
  switch (normalizedField.type) {
1409
+ case "json":
1410
+ return handleJsonType2(value, normalizedField, context);
1375
1411
  case "collection":
1376
1412
  return handleCollectionType2(value, normalizedField, context);
1377
1413
  case "array":
@@ -1972,6 +2008,35 @@ async function handleNestedFields(nested, value, field, context) {
1972
2008
  warnings
1973
2009
  };
1974
2010
  }
2011
+ async function handleJsonType2(value, field, context) {
2012
+ const errors = [];
2013
+ const warnings = [];
2014
+ if (!isObject(field.schema) || !context.validateJson) {
2015
+ return { valid: true, errors, warnings };
2016
+ }
2017
+ const path = context.path.join(".");
2018
+ let fragment;
2019
+ try {
2020
+ fragment = await context.validateJson(field.schema, value);
2021
+ } catch (err) {
2022
+ const message = err instanceof Error ? err.message : "JSON schema validation failed unexpectedly.";
2023
+ return { valid: false, errors: [{ domain: context.domain, path, message }], warnings };
2024
+ }
2025
+ for (const message of fragment.errors ?? []) {
2026
+ errors.push({ domain: context.domain, path, message });
2027
+ }
2028
+ for (const message of fragment.warnings ?? []) {
2029
+ warnings.push({ domain: context.domain, path, message });
2030
+ }
2031
+ if (fragment.valid === false && errors.length === 0) {
2032
+ errors.push({ domain: context.domain, path, message: "JSON value failed schema validation." });
2033
+ }
2034
+ return {
2035
+ valid: errors.length === 0,
2036
+ errors,
2037
+ warnings
2038
+ };
2039
+ }
1975
2040
  async function handlePrimitiveType2(value, field, context) {
1976
2041
  const errors = [];
1977
2042
  const warnings = [];
@@ -2045,6 +2110,18 @@ var JSON_PRIMITIVE_TYPE_MAP = {
2045
2110
  boolean: "boolean"
2046
2111
  };
2047
2112
  function toFormanSchema(field) {
2113
+ const result = toFormanSchemaInternal(field);
2114
+ if (Object.getOwnPropertyDescriptor(field, "x-advanced")?.value === true) {
2115
+ result.advanced = true;
2116
+ }
2117
+ return result;
2118
+ }
2119
+ function toFormanSchemaInternal(field) {
2120
+ if (Object.getOwnPropertyDescriptor(field, "x-json")?.value === true) {
2121
+ const schema = { ...field };
2122
+ delete schema["x-json"];
2123
+ return { type: "json", schema };
2124
+ }
2048
2125
  const compositeType = Object.getOwnPropertyDescriptor(field, "x-composite")?.value;
2049
2126
  if (compositeType === "udttype") return udttypeCollapse(field);
2050
2127
  if (compositeType === "udtspec") return udtspecCollapse(field);
@@ -2180,18 +2257,28 @@ function handleSearchDirective(formanField, directive) {
2180
2257
  }
2181
2258
 
2182
2259
  // src/index.ts
2183
- function toJSONSchema(field) {
2184
- const context = createDefaultContext();
2185
- const result = toJSONSchemaInternal(field, context);
2260
+ function toJSONSchemaAdvanced(field, options) {
2261
+ const context = createDefaultContext(options);
2262
+ const schema = toJSONSchemaInternal(field, context);
2186
2263
  if (Object.keys(context.definitions ?? {}).length > 0) {
2187
- Object.defineProperty(result, "definitions", {
2264
+ Object.defineProperty(schema, "definitions", {
2188
2265
  configurable: true,
2189
2266
  enumerable: true,
2190
2267
  writable: true,
2191
2268
  value: context.definitions
2192
2269
  });
2193
2270
  }
2194
- return result;
2271
+ const skippedPaths = {};
2272
+ if (context.skippedPaths.advanced?.length) {
2273
+ skippedPaths.advanced = context.skippedPaths.advanced;
2274
+ }
2275
+ return {
2276
+ schema,
2277
+ ...Object.keys(skippedPaths).length > 0 ? { skippedPaths } : {}
2278
+ };
2279
+ }
2280
+ function toJSONSchema(field, options) {
2281
+ return toJSONSchemaAdvanced(field, options).schema;
2195
2282
  }
2196
2283
  function validateFormanWithDomains(domains, options) {
2197
2284
  return validateFormanWithDomainsInternal(domains, options);
@@ -2205,6 +2292,7 @@ function validateForman(values, schema, options, restoreExtras) {
2205
2292
  export {
2206
2293
  toFormanSchema,
2207
2294
  toJSONSchema,
2295
+ toJSONSchemaAdvanced,
2208
2296
  validateForman,
2209
2297
  validateFormanWithDomains
2210
2298
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makehq/forman-schema",
3
- "version": "1.13.2",
3
+ "version": "1.15.0",
4
4
  "description": "Forman Schema Tools",
5
5
  "license": "MIT",
6
6
  "author": "Make",