Skip to content
Forms, switch product

Generating a schema tree

Most of a schema tree is mechanical: the envelope, the widget union, the layout union, the vendored core copy and the package index all follow from one list of widget types. @golemui/schemas generates those from a manifest and ships a CLI that scaffolds the whole package, so the real work is writing one component schema per widget.

The single source of truth is a list of { type, schemaFile, kind } entries, one per widget type. This is the shape gui uses:

export const guiWidgetManifest: readonly WidgetManifestEntry[] = [
{ type: 'textinput', schemaFile: 'textinput.schema.json', kind: 'input' },
{
type: 'dateTimeCalendar',
schemaFile: 'datetimecalendar.schema.json',
kind: 'input',
},
{ type: 'flex', schemaFile: 'flex.schema.json', kind: 'layout' },
{ type: 'alert', schemaFile: 'alert.schema.json', kind: 'display' },
// No schemaFile: the renderer props contain a render function, which JSON
// cannot express. Listing it in knownWidgetTypes rejects it in JSON forms.
{ type: 'renderer', kind: 'display' },
{ type: 'button', schemaFile: 'button.schema.json', kind: 'action' },
];

type is the literal that appears in a JSON form, kind is one of input, layout, display or action, and schemaFile names a file inside components/. Leaving schemaFile off marks a type that exists in the builder but has no JSON representation.

The manifest goes into an ImplementationSchemaConfig alongside the texts the generator cannot derive:

FieldMeaning
implementationImplementation name, used in generated titles
idBaseAbsolute base URL of the published tree, with a trailing slash
manifestThe widget manifest
formTitletitle of the generated form envelope
statesDescriptionDescription of the envelope’s states property, naming your suffixable root props
libRootSchemaFilesHandwritten root schema files to re-export, for example validators.schema.json
includeSchemalessTypesInKnownWidgetTypesList builder-only types in the enum so using them in JSON fails with a clear error
includeCustomWidgetFallbackWhether the widget union includes the open custom.schema.json branch
emitEditorBundleAlso emit the self-contained editor bundle. Off by default
generatorPathPath of the generator entry point, written into the generated-file markers
manifestPathFile holding the manifest, for the same markers. Defaults to src/lib/widget-manifest.ts
regenerateCommandCommand that rebuilds the tree, for the same markers. Defaults to npm run generate:schemas

generateImplementationSchemas(config, packageRoot) emits widgets.schema.json, form.schema.json, layout-widget.schema.json, the vendored core/ copy, the editor bundle when enabled, and the package index with a COMPONENT_SCHEMAS_BY_TYPE map. Everything lands under <packageRoot>/src/lib/, the index at <packageRoot>/src/index.ts, and both are overwritten on every run, so hand-written exports belong in another file.

The index exports annotated constants rather than raw JSON modules, so consumers of the built package need neither resolveJsonModule nor import attributes. The generated source itself does default-import the JSON files, so the tsconfig that compiles the tree in your repository needs resolveJsonModule: true and esModuleInterop (or allowSyntheticDefaultImports).

Output goes through prettier when both prettier and a prettier config resolve, and falls back to plain two-space indentation otherwise. Either way it is deterministic: a second run is byte-identical.

@golemui/schemas ships a golemui-schemas bin that creates the whole schema package for a widget set living outside the golemui repository:

Terminal window
npx @golemui/schemas init --name kendo --id-base https://example.com/schemas/kendo/

--dir <path> picks the target directory, --force overwrites starter files that already exist. Run it with no flags in a terminal and it prompts for each one, defaulting the directory to schemas; without a TTY every flag is required and a missing one is an error rather than a silent default. A missing trailing slash on --id-base is added for you.

init writes the tree and then runs generate once:

<dir>/
schemas.config.mjs the manifest and config, owned by you
src/
index.ts generated
lib/
form.schema.json generated
form.editor.schema.json generated (self-contained editor bundle)
widgets.schema.json generated
layout-widget.schema.json generated
core/common.schema.json generated (vendored, $id rebased)
validators.schema.json starter copy of the standard set, owned by you
components/
flex.schema.json starter template (layout, children ref, reserved type name)
example-input.schema.json starter template showing the full component pattern
examples/
example.form.json editor fixture, its $schema points at the editor bundle
test/
schemas.spec.ts Ajv 2020 registration and validation skeleton

The second command rebuilds every generated file from schemas.config.mjs, read from the current directory or from --dir <path>:

Terminal window
npx @golemui/schemas generate --dir schemas

The scaffolded test/schemas.spec.ts needs ajv (2020 dialect) and vitest as devDependencies of your own repository, neither is a dependency of @golemui/schemas. It validates examples/example.form.json, which uses the starter widget type <name>-input, so it fails as soon as you customize the manifest. Replace the example together with the manifest, and validating every examples/*.form.json in a loop is the shape that keeps working.

schemas.config.mjs, the component schemas and the validators schema are yours. Everything else, including the vendored core copy, is rebuilt from the installed @golemui/schemas, so picking up a core change means bumping that dependency and rerunning generate.

Generated files are never edited by hand. The workflow is: edit the manifest or a handwritten schema, rerun the generator, and let CI catch a forgotten rerun:

Terminal window
npx @golemui/schemas generate --dir schemas
git diff --exit-code -- schemas

One schema per widget type is the real per-implementation work. The pattern, as established by the gui components:

  • kind and type as consts.
  • A $ref to the base widget defs in the vendored core/common.schema.json.
  • Per-prop $defs, with additionalProperties: false inside props.
  • patternProperties entries for the state-suffixed form of each suffixable prop, so "label.myState" is accepted alongside "label".
  • unevaluatedProperties: false at the widget root.
  • defaultValue declared on every input widget type, typed per widget. It is a core input field, and a schema that omits it rejects JSON forms that work at runtime.

src/lib/components/example-input.schema.json in a scaffolded tree is a working example of all six, and is the file to copy for each new widget.