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

package.nix takes an entry argument: a single .ts file that imports this library and adds your host-specific glue.

pkgs.callPackage "${sources.nix-module-form}/package.nix" {
  entry = ./my-adapter.ts;
}

The result is a directory containing two files:

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

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/forms-island.css" />
<script type="module" src="/static/forms-island.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 { mountDeploymentForm } from "./index";

const handle = mountDeploymentForm(document.getElementById("form")!, {
  schema,
  value,
  baseline,
  onChange: (data) => {
    /* stash the wire value */
  },
  onDiffChange: (hasDiff) => {
    deployButton.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.

mountDeploymentForm(el, opts)

Mounts the form into el and returns a handle. 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 last deployed configuration, which each field is highlighted against. Omitted means the deployment was never deployed: the baseline is the empty config, so every set field reads as a change.
schemaChanges SchemaChangeMap? Per-path record of which options were added, removed, renamed or retyped between the schema the config was last deployed under 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 a Deploy button.

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 Deploy gate: 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.

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)

Applies a structured rename table to a stored value before it reaches the form, so an operator 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.

A Rename is { from: Segment[], to: Segment[] }, where a Segment is a literal key or null -- a wildcard for an attrsOf/listOf level, expanded across whichever concrete keys the value has.

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

computeSchemaChanges(oldSchema, newSchema, renames)

Returns a SchemaChangeMap (Map<string, SchemaChange>) describing what changed between the schema the config was last deployed under 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(deployedSchema, currentSchema, renames),
  new Set(applied.map((r) => r.to.join("."))),
);
mountDeploymentForm(el, {
  schema: currentSchema,
  value: migrated,
  baseline,
  schemaChanges: changes,
});

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 four spellings, because the generator has emitted different ones over time and a legacy clan-core fork emitted a fourth. All four 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 }
  4. oneOf two-branch -- the legacy clan fork's spelling:
    { "oneOf": [{ "type": "null" }, { "type": "string" }] }

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" }; that shape is a nullOr instead.

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.

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 Deploy button. Everything host-specific lives in your entry. That is a real cost: the fediversity panel's adapter is around 630 lines, nearly all of it API calls, auth tokens and hidden-input plumbing. The demo entry is the same contract with all of that stripped away, at roughly 50 lines of code.

Defaults are not filled in. JSONForms' useDefaults is deliberately off. A Nix default is a fallback, not an operator choice -- baking it into the form data would store today's default verbatim and pin the deployment 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>__fviOld fields showing the last-deployed value. 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. mountDeploymentForm mounts a fresh Vue app into the element you hand it. Mount twice without emptying it first and you get two forms.

Styling hooks. The form's own classes are namespaced fvi-* and are stable enough to style from the host page: .fvi-diff-leaf and .fvi-diff-added / .fvi-diff-changed / .fvi-diff-removed for the per-field value diff; .fvi-schema-added / .fvi-schema-changed / .fvi-schema-old-field for the schema-change layer; .fvi-diff-legend-item and .fvi-schema-legend-item for legend swatches carrying the same colors. Note that forms-island.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.