Usage

Integration recipe

1. Pin the repository

With npins:

npins add --name nix-module-form forgejo https://git.fediversity.eu/ fediversity nix-module-form --branch main

Or as a flake input:

inputs.nix-module-form.url = "git+https://git.fediversity.eu/fediversity/nix-module-form";

2. Build the bundle with your own entry

The package packages.default takes an entry argument: a single .ts file that imports this library and adds your host-specific glue. Set it with override:

(import sources.nix-module-form { inherit pkgs; }).packages.default.override {
  entry = ./my-adapter.ts;
}

With a flake input, use inputs.nix-module-form.packages.${system}.default.override { ... }. package.nix is an internal file of this repository; do not call it directly.

The result is a directory containing two files:

  • nix-module-form.js -- an ES module, the whole bundle (Vue, Vuetify, JSONForms and the renderers are all inlined; there is nothing else to serve).
  • nix-module-form.css -- all the styles, including Vuetify's.

The directory also has forms-island.js and forms-island.css, links to the same two files under the names of earlier versions.

With entry = null (the default) you get the standalone library bundle built from src/index.ts, which exports the API below but mounts nothing. That is what nix build .#packages.x86_64-linux.default produces.

3. Serve the two files

<link rel="stylesheet" href="/static/nix-module-form.css" />
<script type="module" src="/static/nix-module-form.js"></script>

Both must be served over HTTP. A browser refuses to load <script type="module"> from a file:// origin.

4. Mount the form from your entry

Your entry file is copied into src/ before the build, so it imports the library by relative path:

import { mountForm } from "./index";

const handle = mountForm(document.getElementById("form")!, {
  schema,
  value,
  baseline,
  onChange: (data) => {
    /* stash the wire value */
  },
  onDiffChange: (hasDiff) => {
    submitButton.disabled = !hasDiff;
  },
});

demo/demo-entry.ts in this repository is a complete, commented worked example of exactly this -- see Demo.

API reference

Everything below is exported from src/index.ts, which is the entire public surface.

mountForm(el, opts)

Mounts the form into el and returns a handle. mountDeploymentForm is the old name of this function: a deprecated alias that stays for one release. opts is a MountOptions:

field type meaning
schema JsonSchema Required. The JSON Schema to render.
uischema UISchemaElement? Optional JSONForms UI schema. Omitted in practice -- the renderers key off the data schema, so field order follows the schema.
value unknown Required. The configuration to load into the form.
baseline unknown? The value in effect (for example, the last deployed configuration), which each field is highlighted against. Omitted means there is no value in effect: the baseline is the empty value {}, so every field that value sets reads as a change.
oldValues unknown? The value that the old fields of schemaChanges show, at the old path of each change. Omitted means baseline. Give the stored value here when you drop the keys of a removed option from value, so that the old field shows what a save removes.
schemaChanges SchemaChangeMap? Per-path record of which options were added, removed, renamed or retyped between the schema of the baseline and the current one. Omit when the module source is unchanged (the common case).
onChange (data: unknown) => void Called after every edit with the stripped wire value. Not guaranteed to fire at mount -- it only fires when the form's data actually changes, which for an already-complete value need not happen.
onDiffChange (hasDiff: boolean) => void Called at mount and after every edit with whether the form now differs from the baseline. Use it to gate the submit control of the host.
collapsible boolean? Render each object section (a submodule, an attrsOf map, a listOf of submodules, a nullOr/attrTag object) behind a header the user opens, instead of one flat column. Defaults to true. See Collapsible sections.
progressive boolean | { budget?: number }? Render the form in batches: each object section shows a placeholder first and fills in over the following frames, so a large schema paints its top immediately. On by default: the DOM then grows after the mount call returns, so await settled. false renders the whole form in the mount call. budget tunes the leaf controls revealed per frame.
primaryColor { light?: string; dark?: string }? The primary color of the form (inputs, switches) per theme, as a CSS color. A field that is not set keeps the default (#3b5bdb light, #748ffc dark). All forms on one page share the color: the last mount sets it.

The returned handle:

member type meaning
app App The underlying Vue application, for unmounting.
validate(value?) => { path, message }[] Runs full AJV validation against the schema. Empty means valid. A non-empty result also reveals the errors inline (the form starts in "validate on submit" mode). value defaults to the live form value.
attemptSubmit(value?) => string[] validate projected to the failing paths. The submit gate of the host: a non-empty result means do not submit.
recomputeDiffAgainst(value) => void Re-runs the diff (and so onDiffChange) against an externally supplied value. Only needed when something outside JSONForms writes the canonical value.
expandAll() => void Opens every collapsible section. A no-op when collapsible is false.
collapseAll() => void Closes every collapsible section. A no-op when collapsible is false.
settled Promise<void> Resolves once every section of the form is rendered. Await this before you capture the page or measure a layout. With progressive: false it resolves on the next frame.

validateConfig(schema, data, validator?)

The pure validation core behind handle.validate, with no DOM and no mounted form. Returns { path, message }[]. Exported so a host can validate a value it is not currently rendering.

resolveTheme() and setTheme(theme)

resolveTheme(): "light" | "dark" reads the host page's <html data-theme>, falling back to the OS prefers-color-scheme when the attribute is absent. It is called once at mount.

setTheme("light" | "dark") switches the mounted form's theme. The library does not watch for theme changes -- the embedder calls this from whatever event its page emits.

migrateValue(value, renames, storedVersion?)

Applies a structured rename table to a stored value before it reaches the form, so a user sees their data under the current field names. Returns { value, unmigrated, applied }, where applied lists the concrete { from, to } pairs whose data actually moved for this value.

renames is a RenameTable, see The rename table.

storedVersion is the version of the table that the stored value is current with. Only the entries that are newer than it apply. Omit it, or give null, when the version of the value is not known: then the whole table applies.

This is migrate-on-read; the stored value is untouched.

latestVersion(renames)

The version that a value is current with after migrateValue applied renames to it: the largest since in the table, or 0 when no entry has a since. Store it with the value, so that the next migration starts from it.

applicable(renames, storedVersion?)

The entries of renames that migrateValue applies for storedVersion, in table order.

unofferedPaths(value, schema) and pruneUnoffered(value, schema, keep?)

A stored value can have a key that the current schema does not offer: an option that upstream removed, an option that is no longer operator-facing, or other foreign data. The form keeps such a key in its data and sends it back on submit. Thus a backend that rejects a value that does not fit the schema rejects each submission again.

unofferedPaths returns the path of each such key, as a list of segments. pruneUnoffered returns { value, dropped }: a copy of value without these keys, and their paths. The input does not change. When no key is unoffered, value is the input itself. Call it on the migrated value from migrateValue, before you mount the form, and show the user the dropped paths. A path for which keep(path) returns true stays. Use it to keep the old path of each rename in unmigrated: its data has no other place in the value.

The rules:

  • A key is unoffered where an object schema has additionalProperties: false and neither properties nor patternProperties names the key.
  • The walk goes into properties, patternProperties, an additionalProperties schema, items, prefixItems, allOf and local $refs (#/...). A segment for a list element is its index as a string.
  • In a oneOf or anyOf, a key is unoffered only if each branch reports it or one of its parent keys. A branch whose type does not include the type of the value reports the value itself. Thus for a nullOr submodule, the object branch decides.
  • Only object keys are dropped. A value of the wrong type, a list element and the value that the walk starts at always stay.
  • The walk ignores if/then/else, dependentSchemas, unevaluatedProperties, not and propertyNames. Thus it can miss a key that the schema does not accept, but it does not drop a key that the schema accepts.

The rename table

This library defines the format of the rename table (RenameTable, exported from the entry point with Rename and Segment). A producer of the table, for example the build of an embedder that compares two versions of a module, writes this format, and a migration of a stored value outside the browser follows the same rules.

The table is a JSON array. Each entry is an object { "from": [...], "to": [...] }: the old path and the current path of one option. Each path segment is a string (a literal key) or null (a wildcard for an attrsOf or listOf level).

An entry can also have since: the version of the table that the entry was added in, an integer. Use it when the table is a log across releases, because then the value alone does not show which entries apply to it. If upstream renames bar to baz, then renames foo to bar, then renames bar to boo, a stored { "bar": 1 } goes to baz when it was saved before the first rename, and to boo when it was saved after the second one. An entry without since applies to every value.

[
  { "from": ["adminMail"], "to": ["adminEmail"], "since": 1 },
  { "from": ["services", null, "port"], "to": ["services", null, "listen", "port"], "since": 2 }
]

The rules of migrateValue:

  • When there is a stored version, only the entries with no since or with a since larger than the stored version apply. The table order does not have to be the since order.
  • The entries apply in order, so a later entry sees the result of an earlier one. A chain a -> b, b -> c moves a to c.
  • A wildcard expands to each key that the value has at that level. The wildcards of from and to align by position. When from and to have a different number of wildcards, the entry goes to unmigrated with the reason wildcard-arity-mismatch.
  • When the value has no data at from, the entry does nothing.
  • When the value already has data at to, the data stays at from, and the entry goes to unmigrated with the reason target-occupied.
  • After a move, the empty parent objects of from are removed.
  • An entry with an empty from or to is skipped.
  • A value that is not an object is returned unchanged.

computeSchemaChanges(oldSchema, newSchema, renames)

Returns a SchemaChangeMap (Map<string, SchemaChange>) describing what changed between the schema of the baseline and the current one. Each entry has a kind of added, removed, renamed or changed, keyed by the current data path -- except removed, which has no current path and is keyed by its old one.

restrictRenamesToApplied(changes, appliedToPaths)

Narrows a SchemaChangeMap to the renames that this particular value was actually migrated across, so a field is not annotated "renamed from X" for a rename that never touched it. Pass the to paths from migrateValue's applied. Returns a new map.

The three compose into the schema-change workflow:

const { value: migrated, applied } = migrateValue(stored, renames);
const changes = restrictRenamesToApplied(
  computeSchemaChanges(baselineSchema, currentSchema, renames),
  new Set(applied.map((r) => r.to.join("."))),
);
mountForm(el, {
  schema: currentSchema,
  value: migrated,
  baseline,
  schemaChanges: changes,
});

Collapsible sections

Each object section renders behind a header the user opens: a submodule, an attrsOf map, a listOf of submodules, and a nullOr or attrTag object. A deep schema is then a short list of headers, not one column of several hundred controls.

Sections start closed, except those that hold something the user must see:

  • a value that differs from the baseline,
  • an option the schema changed (schemaChanges),
  • a field that handle.validate() rejected, once a validation run occurred.

Such a signal also opens the ancestors of the field, so the field is on screen.

A closed header reports what is behind it: the number of changed values, then the number of entries of a map, the number of items of a list, the active tag of a tagged union, or the number of options of an object.

The form renders an Expand all / Collapse all row above the fields. handle.expandAll() and handle.collapseAll() do the same, for a host that puts the controls somewhere else.

A closed section keeps its body in the DOM; the hidden attribute alone takes it off the page. Thus a DOM query or a script finds every control, open or closed, and a renderer that seeds the value it renders -- the default branch of a tagged union, the Configure switch of a nullOr -- does that at mount as usual.

To style the sections, use .nmf-section (with .nmf-section-open / .nmf-section-closed), .nmf-section-header, .nmf-section-label, .nmf-section-badge, .nmf-section-body and .nmf-section-toolbar. The data-nmf-section attribute holds the data path of the section and data-nmf-collapsed its state.

Set collapsible: false to get the flat column back, for example for a host that captures the whole form as an image.

Schema shapes

This is the contract with the schema generator: which JSON Schema shapes get a dedicated renderer, and how they are spelled. Anything not listed falls through to the stock JSONForms Vuetify renderers.

nullOr

Recognized in three spellings, because the generator has emitted different ones over time. All three render as a "Configure / use the default" toggle with the inner control beneath it:

  1. Merged scalar type array -- nullOr str:
    { "type": ["null", "string"], "default": null }
  2. Merged object type array -- nullOr (submodule ...), with the properties co-located on the node:
    { "type": ["null", "object"], "properties": {}, "default": null }
  3. anyOf two-branch -- nullOr (enum [...]), a node with no type:
    { "anyOf": [{ "type": "null" }, { "enum": ["a", "b"] }], "default": null }

A oneOf with a { "type": "null" } branch is not a nullOr.

attrTag (tagged unions)

A oneOf of two or more object branches, each with exactly one required property that is also its only property -- the property name being the tag. None of the branches may be { "type": "null" }.

Two spellings are accepted. Each branch may state its own type: "object", or the union node may hoist the shared type up -- which is what fediversity/module-schema does, since additionalProperties cannot follow it up. A branch with no type is accepted as long as the union node supplies type: "object".

{
  "type": "object",
  "oneOf": [
    {
      "properties": { "ssh-host": { "$ref": "#/$defs/SshHost" } },
      "required": ["ssh-host"],
      "additionalProperties": false
    },
    {
      "properties": { "local": { "$ref": "#/$defs/Local" } },
      "required": ["local"],
      "additionalProperties": false
    }
  ]
}

Renders as a native <select> over the tags with only the active branch's fields below it. Switching tags carries over the settings both branches declare identically -- deep-equal schema nodes, so the carried value is guaranteed to still validate -- and remembers the rest for as long as the form is mounted. A branch property is usually a $ref into $defs, which is dereferenced before dispatch (internal #/$defs/... refs only).

attrsOf

An object with additionalProperties and, usually, a propertyNames constraint naming the Nix attrName key type:

{
  "type": "object",
  "additionalProperties": { "type": "string" },
  "propertyNames": { "$ref": "#/$defs/attrName" }
}

Renders as a map with a new-key input. See the AJV gotcha below -- this shape is the reason the build carries a vendored patch.

Secrets (writeOnly)

{ "type": "string", "writeOnly": true }

writeOnly: true marks a secret, such as a password. The keyword can also sit on a $ref or on a nullable anyOf wrapper. A secret string renders as a password input, which hides the value until the user selects the eye icon. When schemaChanges gives the secret an old field, that field shows only the label and the note secret value, not shown, and the form data does not contain the old value. The widget treats an option as secret when the old or the current schema marks it. module-schema does not emit writeOnly yet, so add it to the schema yourself.

The rest

listOf renders as the stock array control, submodule as a nested object, enum as a <select>, and scalars (string, integer, number, boolean) as their Vuetify controls -- booleans as a switch rather than a checkbox. An option's description becomes the field's help text and its default becomes a greyed-out placeholder.

Gotchas

The entry file is copied into src/. package.nix does cp ${entry} src/${baseNameOf entry} before the build, so your entry must import the library by relative path (./index, ./migrate), not by package name. Your editor will flag those imports as unresolved, since the file only lives in src/ inside the build sandbox. That is expected.

The library is host-free by design. It does not auto-mount, define window globals, fetch anything, or render a submit control. Everything host-specific lives in your entry: how it loads the value, how it stores or submits it, and how it authenticates. That is a real cost, and it grows with the host. The demo entry is the contract with no host parts, at roughly 50 lines of code.

Defaults are not filled in. JSONForms' useDefaults is deliberately off. A Nix default is a fallback, not a user choice -- baking it into the form data would store today's default verbatim and pin the configuration to it if the default later changes. So an absent key in the wire value means "follow the current default". The default is still shown, as a greyed-out placeholder.

Validate the canonical value, not the form state. If anything other than JSONForms can write the value your host submits -- a hidden input written by tests, devtools, or a "load template" button -- pass that value explicitly: handle.validate(value) / handle.attemptSubmit(value). Otherwise the gate checks form state that may lag behind what is actually submitted. recomputeDiffAgainst(value) exists for the same reason, on the diff side.

Read the wire value from onChange, not the DOM. When schemaChanges marks an option as changed or removed, the form injects synthetic read-only <key>__nmfOld fields showing the value of oldValues or the baseline (except for a secret). They are stripped from everything that leaves the form, but they are in the rendered schema and data. onChange's argument is already stripped; scraping the form is not.

Theme changes need setTheme(). resolveTheme() runs once, at mount. Setting <html data-theme> afterwards restyles your page but not the mounted form until you also call setTheme().

Re-mounting requires clearing the host element. mountForm mounts a fresh Vue app into the element you hand it. Mount twice without emptying it first and you get two forms.

Style isolation. The root element of the form has the class nmf-form, and the rules of the stylesheet apply only under it or to nmf-* classes, so the stylesheet does not change the host page. In the other direction, the form renames the generic class tokens control and input of vue-vuetify to nmf-control and nmf-input, so that a host rule for .control or .input does not reach the fields.

Styling hooks. The form's own classes are namespaced nmf-* and are stable enough to style from the host page: .nmf-diff-leaf and .nmf-diff-added / .nmf-diff-changed / .nmf-diff-removed for the per-field value diff; .nmf-schema-added / .nmf-schema-changed / .nmf-schema-old-field for the schema-change layer; .nmf-diff-legend-item and .nmf-schema-legend-item for legend swatches carrying the same colors. Note that nix-module-form.css sets .v-application to background: transparent on purpose -- the host page owns the surface the form sits on, so give it one.

The bundle carries a vendored AJV patch. @jsonforms/vue-vuetify's additional-properties renderer mounts a nested form for a map's new-key input and gets it wrong twice: it passes ajv: undefined (so the nested form builds a default AJV with unicodeRegExp: true, which rejects the Nix attrName pattern as a Lone quantifier brackets syntax error), and it spreads propertyNames verbatim without resolving a $ref into the root's $defs. Either one makes an attrsOf field throw at mount. package.nix patches both at build time. If you build outside package.nix, apply patches/vue-vuetify-additional-properties-ajv.nix yourself or your maps will not render. Both bugs are filed upstream.

The bundle also carries a vendored @jsonforms/vue patch. Unpatched, each edit gives every control a new props object, also when its props did not change, so the whole form renders again after each keystroke or switch. On a form with a few hundred controls that takes hundreds of milliseconds. patches/jsonforms-vue-stable-control.nix keeps the previous props object when the new one is equal, so an edit renders only the controls it changes. Without it the form still works, but slowly.