Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter

## Project map

- `src/index.ts` — public API: re-exports `toJSONSchema`, `toFormanSchema`, `validateForman`, `validateFormanWithDomains`, and all types
- `src/index.ts` — public API: re-exports `toJSONSchema`, `toFormanSchema`, `validateForman`, `validateFormanWithDomains`, `fieldEdges`, `activeFieldEdges`, and all types
- `src/types.ts` — all type definitions
- `src/forman.ts` — `toJSONSchema` conversion
- `src/json.ts` — `toFormanSchema` conversion
- `src/validator.ts` — validation engine
- `src/edges.ts` — `fieldEdges` / `activeFieldEdges`, the normalized reading of how a field reveals children
- `src/utils.ts` — shared helpers
- `src/composites/` — composite field type handlers (`udtspec.ts`, `udttype.ts`) used by forman, json, and validator
- `test/` — tests (`*.spec.ts`, `directives/*.spec.ts`, `composites/*.spec.ts`); fixtures in `test/mocks/`
Expand All @@ -30,14 +31,16 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter

**Forman Schema** is Make's proprietary form field format. A schema is an array of `FormanSchemaField` objects. Each field has a `type` (one of ~40 types), optional `spec` (sub-fields for `collection`/`array`), `options` (select options or `rpc://` URL), `nested` (fields revealed by the value of a select or boolean), and `validate`.

**Field edges** (`fieldEdges` / `activeFieldEdges` in `src/edges.ts`) are the normalized view of how a field reveals children, following the validator's reading: per-option `nested` (plain array, `store`, or inside an option group) matched on the key named by `options.value`, `options.placeholder.nested` (gated on `''`, non-required `select` only), the field's own `nested` or — only when absent — `options.nested`, the boolean `{ true, false }` form, and the `{ store, domain }` wrapper around any of them. Boolean-ness goes through `resolveFormanFieldType`, so aliases and casing resolve the same way as in the converter. `fieldEdges` is structural (every reachable edge); `activeFieldEdges` applies the validator's selection rules — a matching gated edge replaces the unconditional ones, an out-of-options value falls back to them, an empty value reveals only the placeholder edge, a boolean holding an IML value reveals its single-branch `nested` and nothing of the two-branch form. The converter and validator still read `options`/`nested` through their own helpers (`extractNestedAndDomain`, `extractNestedFromField`), and they disagree when a field declares both `options.nested` and `nested` (converter reads the former, validator the latter). `test/field-edges.spec.ts` pins `activeFieldEdges` to the validator's `resolvedSchemas` per selection rule and over the google-sheets mock; a new selection rule in the validator needs a row in that table.

**Domains** are named scopes (e.g. `default`, `additional`) used in multi-domain validation. Fields in one domain can reveal nested fields in another domain via `field.nested.domain`. The `x-domain-root` property on a `collection` field registers it as the anchor for cross-domain routing.

<important if="you are modifying toJSONSchema conversion or the forman.ts file">

**Public signatures:**

- `toJSONSchema(field, options?)` returns a bare `JSONSchema7` (backward-compatible). Delegates to `toJSONSchemaAdvanced` and returns `.schema`.
- `toJSONSchemaAdvanced(field, options?)` returns `{ schema: JSONSchema7, skippedPaths?: { advanced?: string[] } }`. `skippedPaths` is omitted entirely when nothing was skipped.
- `toJSONSchemaAdvanced(field, options?)` returns `{ schema: JSONSchema7, skippedPaths?: { advanced?: string[]; unconvertible?: string[]; remoteFragments?: string[] } }`. `skippedPaths` is omitted entirely when nothing was skipped.

**Forman types → JSON Schema types** mapping is in `src/forman.ts` at `FORMAN_TYPE_MAP`. Notable: `collection→object`, `array→array`, `filter→array`, `checkbox→boolean`, `hidden/any→undefined`.

Expand All @@ -47,7 +50,11 @@ TypeScript library for converting and validating **Forman Schema** (Make's inter

**Unresolvable types degrade, they do not throw** (default). `degradeUnconvertibleField` returns the permissive `any` shape and records the path on `context.skippedPaths.unconvertible`; `{ strictFieldTypes: true }` restores throwing. The old behaviour threw at the top of _every_ recursive call, so one bad leaf field aborted the entire schema — in production this made Maia conclude the user's module was broken and replace it. Note the validator has always been tolerant of unknown types (`if (expectedType && ...)` skips the check), so this aligns the two paths rather than loosening one. A field with **no** type is still a structured `validateForman` error — it used to crash with a raw `TypeError` from `normalizeFormanFieldType`.

Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleCollectionType`, `handleArrayType`, `handleSelectOrPathType`, `handleFilterType`, `handlePrimitiveType`. `ConversionContext` carries `domain`, `path`, `tail`, `roots`, `addConditionalFields` callback (for select-with-nested → `allOf[if/then]` generation on parent collection), `excludeAdvancedFields` (default `false`), `strictFieldTypes` (default `false`), and `skippedPaths` (mutable accumulator, keyed by skip reason — `advanced` and `unconvertible` — shared across recursion via context spread). `SchemaConversionError` is also defined here.
Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleCollectionType`, `handleArrayType`, `handleSelectOrPathType`, `handleFilterType`, `handlePrimitiveType`. `ConversionContext` carries `domain`, `path`, `tail`, `roots`, `addConditionalFields` callback (for select-with-nested → `allOf[if/then]` generation on parent collection), `excludeAdvancedFields` (default `false`), `strictFieldTypes` (default `false`), `excludeRemoteFragments` (default `false`), and `skippedPaths` (mutable accumulator, keyed by skip reason — `advanced`, `unconvertible` and `remoteFragments` — shared across recursion via context spread). `SchemaConversionError` is also defined here.

**Remote fragment exclusion:** `excludeRemoteFragments` is applied by `withoutRemoteFragments(list, path, context)` at the three places a field _list_ is read — `handleCollectionType.addField` (a bare string in `spec`), `extractNestedAndDomain` (the field-level `nested` / `options.nested`, once, before the per-option fallback and `processNestedDirective` reuse it), and an option's own `nested` in `handleSelectOrPathType`. Paths come from `fieldPath` (the `collectionPath` rule: the field's name, or the enclosing path for a nameless field), so a fragment is reported at the field or collection declaring it — a cross-domain `nested` at its origin field, not the receiving root. A list left empty emits no `x-nested` marker or `then` branch. A whole-list remote (`nested: "rpc://…"`, string not array) is untouched: it becomes `x-nested: { $ref }` / `then: { $ref }`, a marker on the field rather than a fragment in a list.

**Editor/multiline markers:** `handlePrimitiveType` stamps `x-editor: true` and `x-language` for `type: 'editor'` (the converter's `FORMAN_TYPE_MAP` has `editor: 'string'`; the validator's does not, so it skips the type check on editor values) and `x-multiline: true` for `multiline: true` on string-typed results only, since `toFormanSchema` reads both back in its string branch.

**Advanced field tracking:** filter point is `handleCollectionType.addField`. Fields with `advanced: true` are **included by default** and stamped with `x-advanced: true` (enumerable, configurable, writable). When `excludeAdvancedFields: true` is passed, they're omitted from the schema and their paths accumulate in `context.skippedPaths.advanced`. Path segments are built via the `collectionPath` helper, which is `[...context.path, field.name]` when `field.name` is set, else `context.path` (this handles synthetic anonymous collection wrappers — array items, nested-by-option, RPC params, composite expansions — cleanly so `[]` array paths don't get a literal `"undefined"` segment). Composite types (`udtspec`, `udttype`) memoize via `context.definitions[type]`; advanced fields inside a composite are recorded once per `toJSONSchemaAdvanced` call, not per usage.
</important>
Expand All @@ -68,7 +75,7 @@ Entry: `toJSONSchemaInternal(field, context)`. Dispatches by type to `handleColl

<important if="you are modifying x-* round-trip markers or round-trip conversion">

**`x-*` round-trip markers** — Forman-specific metadata that JSON Schema doesn't have a native slot for is attached via `Object.defineProperty` on JSON Schema output objects: `x-filter`, `x-path`, `x-fetch`, `x-nested`, `x-search`, `x-advanced`, `x-composite`, `x-filestorage`, `x-json`. Most are declared `enumerable: true` (so they DO appear in `JSON.stringify` output and are part of the serialized schema); `x-filestorage` is the exception at `enumerable: false`. `defineProperty` is used (rather than plain assignment) to keep these out of the structural TypeScript shape of `JSONSchema7` and to keep them isolated from spec-compliant property handling. `toFormanSchema` reads them back via `Object.getOwnPropertyDescriptor`. For `x-advanced`, recovery happens in the top-level `toFormanSchema` wrapper (after delegating to `toFormanSchemaInternal`) so all branches — including composite short-circuits — inherit it uniformly.
**`x-*` round-trip markers** — Forman-specific metadata that JSON Schema doesn't have a native slot for is attached via `Object.defineProperty` on JSON Schema output objects: `x-filter`, `x-path`, `x-fetch`, `x-nested`, `x-search`, `x-advanced`, `x-composite`, `x-filestorage`, `x-json`, `x-editor`, `x-language`, `x-multiline`. Most are declared `enumerable: true` (so they DO appear in `JSON.stringify` output and are part of the serialized schema); `x-filestorage` is the exception at `enumerable: false`. `defineProperty` is used (rather than plain assignment) to keep these out of the structural TypeScript shape of `JSONSchema7` and to keep them isolated from spec-compliant property handling. `toFormanSchema` reads them back via `Object.getOwnPropertyDescriptor`. For `x-advanced`, recovery happens in the top-level `toFormanSchema` wrapper (after delegating to `toFormanSchemaInternal`) so all branches — including composite short-circuits — inherit it uniformly.

**`json` type** — a `json` field carrying an explicit `schema` (a `JSONSchema7`) echoes that schema verbatim (`handleJsonType` in `forman.ts`), letting complex schema parts be authored directly in JSON Schema and mixed with primitive Forman fields. The schema is shallow-cloned (caller input never mutated); `label`/`help` fill `title`/`description` only when the echoed schema omits them. An enumerable `x-json: true` marker (stripped from the recovered `schema`) lets `toFormanSchemaInternal` short-circuit back to `{ type: 'json', schema }`, and survives JSON serialization. A `json` field **without** a `schema` returns the pre-built `result` as-is — `{ type: 'object' }` (the `FORMAN_TYPE_MAP` entry) plus any `title`/`description` — so it round-trips to `dynamicCollection` (empty object → dynamic collection).
</important>
Expand Down
82 changes: 82 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

Conversion and validation utilities for Forman Schema.

## v2.1.0 — field edges, editor markers, remote fragment exclusion
Comment thread
andriichumak marked this conversation as resolved.

Non-breaking minor release: new exports, new markers and a new opt-in conversion option; nothing
existing is renamed or changes behaviour.

- New `fieldEdges(field)` and `activeFieldEdges(field, value)` expose every way a field reveals child
fields as one normalized list of edges — see [Reading child fields](#reading-child-fields).
- `editor` fields are stamped with `x-editor: true` (plus `x-language`), multiline text with
`x-multiline: true`; both round-trip through `toFormanSchema`.
- New conversion option `excludeRemoteFragments` drops remote form fragments (bare strings in field
lists) and reports them on `skippedPaths.remoteFragments` — see
[Remote form fragments](#remote-form-fragments).
- `FormanSchemaExtendedOptions.store` is optional, matching schemas whose `options` wrapper carries
only `nested`, and accepts a partially grouped store.

## v2.0.1 (patch): inactive branches stay out of `schemas`

The fields nested under a boolean toggle that is `false` (or absent and filled to `false` by
Expand Down Expand Up @@ -76,6 +91,73 @@ const { schema, skippedPaths } = toJSONSchemaAdvanced(formanField, { excludeAdva

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`.

### Remote form fragments

A field list may hold a bare string next to its fields — a form fragment fetched live, such as a banner
or a record schema behind `rpc://…`, or a platform form behind `api://…`. By default it converts to an
`allOf: [{ $ref: "rpc://…" }]` entry on the enclosing object. Pass `{ excludeRemoteFragments: true }` to
drop every such string instead; `toJSONSchemaAdvanced` reports each dropped fragment once, as the dot
path of the field or collection declaring the list plus the reference:

```typescript
const { schema, skippedPaths } = toJSONSchemaAdvanced(
{ name: 'wrapper', type: 'collection', spec: ['rpc://banner', { name: 'a', type: 'text' }] },
{ excludeRemoteFragments: true },
);
// schema.allOf → undefined
// skippedPaths → { remoteFragments: ['wrapper (rpc://banner)'] }
```

A list left empty by the exclusion emits no `x-nested` marker and no `allOf` branch. A field whose
_whole_ child list is remote (`nested: "rpc://…"`) is unaffected — that stays an `x-nested: { $ref }`
marker on the field, since it is not a fragment inside a list.

### Editor and multiline markers

`type: 'editor'` converts to a string schema stamped with `x-editor: true` and, when the field declares
a `language`, `x-language: '<language>'`. A string-typed field (`text`, `editor`, …) with
`multiline: true` is stamped with `x-multiline: true`. Both are enumerable, so they survive
serialization, and `toFormanSchema` reads them back into `type: 'editor'`/`language` and
`multiline: true`.

### Reading child fields

A Forman field can reveal children in several spellings: per-option `nested` (in a plain `options`
array, an `options.store`, or an option group), `options.placeholder.nested`, `options.nested`, the
field's own `nested`, the boolean `{ true, false }` form, and the `{ store, domain }` wrapper around any
of them. `fieldEdges(field)` normalizes all of these into one list of edges, so a consumer walking a
form never reads `options`/`nested` directly:

```typescript
import { fieldEdges, activeFieldEdges } from '@makehq/forman-schema';

fieldEdges({
name: 'mode',
type: 'select',
options: {
store: [{ value: 'a', nested: [{ name: 'onA', type: 'text' }] }, { value: 'b' }],
nested: { domain: 'expect', store: [{ name: 'always', type: 'text' }] },
},
});
// [
// { gate: { name: 'mode', value: 'a' }, children: [{ name: 'onA', type: 'text' }] },
// { domain: 'expect', children: [{ name: 'always', type: 'text' }] },
// ]
```

An edge carries `gate` when the children depend on the parent's value, `domain` when they belong to
another domain, and either `children` (a static list, bare `rpc://` strings kept verbatim) or `remote`
(the whole list is fetched live). The list follows the validator's reading of the schema: an option is
matched on the key named by `options.value` (default `value`); the field's own `nested` shadows
`options.nested` when both are declared; `placeholder.nested` counts only on a non-required `select`; a
boolean's `nested` is an edge gated on `true`, or on `false` under `reversedNested`.

`activeFieldEdges(field, value)` returns the edges a given value reveals, with the validator's rules: a
matching gated edge replaces the unconditional ones, a value outside the static options falls back to
them, and an empty value (`undefined`, `null`, `''`) reveals only the placeholder edge. A boolean
holding an IML expression reveals its single-branch `nested` whatever the toggle, and nothing of the
`{ true, false }` form.

### Converting from JSON Schema to Forman Schema

```typescript
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@makehq/forman-schema",
"version": "2.0.1",
"version": "2.1.0",
"description": "Forman Schema Tools",
"license": "MIT",
"author": "Make",
Expand Down
Loading
Loading